167 Commits
v0.1.0 ... main

Author SHA1 Message Date
Ante Brähler
d9705451ce chore: update readme with outdated notes 2025-11-18 18:43:10 +01:00
Ante Brähler
c4146de1e2 Merge pull request #83 from antebrl/72-backend-exits-upon-launch
72 backend exits upon launch
2025-11-13 18:31:58 +01:00
antebrl
4a55c4575e feat: improve channel deletion logic 2025-11-13 18:28:52 +01:00
antebrl
62a3b506b5 chore: update default channels 2025-11-13 18:14:30 +01:00
antebrl
3e2259eaef feat: add issue templates for help wanted and questions 2025-10-29 22:31:52 +01:00
Ante Brähler
40bdcba29f Merge pull request #81 from antebrl/79-authorization-issue-in-the-api
Authorization issue in the API
2025-10-29 21:39:53 +01:00
antebrl
7d032c2703 fix: check if adminMode is enabled in the api 2025-10-29 21:33:34 +01:00
Ante Brähler
928a4bf52a Merge pull request #65 from aronjanosch/simple-admin-mode
Admin mode for limiting access to channel management
2025-10-07 23:24:41 +02:00
antebrl
e226dd507f docs: explain admin mode 2025-10-07 23:23:05 +02:00
antebrl
8b2cd8a1c9 Merge remote-tracking branch 'origin/main' into simple-admin-mode 2025-10-07 23:10:19 +02:00
antebrl
4fe017a15c feat: introduce CHANNEL_SELECTION_REQUIRES_ADMIN option 2025-10-07 23:09:43 +02:00
Ante Brähler
6a40e4fd7d docs: remove test-server for maintainance 2025-06-26 00:51:40 +02:00
antebrl
b5c0769654 refactor: use authService in backend 2025-05-03 19:09:32 +02:00
antebrl
f0ab4f70ba refactor: remove unused isAdmin parameter in frontend socket 2025-05-03 19:09:16 +02:00
antebrl
170de2da33 refactor: extract authService and use hashed admin_password as jwt_secret 2025-05-03 18:40:24 +02:00
Aron Wiederkehr
ee16181219 refactor: Remove unused state and simplify sensitive info handling in modals 2025-05-02 12:36:03 +02:00
Aron Wiederkehr
61646cf3fc feat: Enhance socket connection management with JWT token handling and admin status updates 2025-05-02 12:34:58 +02:00
Aron Wiederkehr
a5670cd1b2 feat: Add explicit CORS configuration for WebSocket server 2025-05-02 12:20:31 +02:00
Aron Wiederkehr
e752c36c2d fix: jwt-decode import 2025-05-02 12:20:18 +02:00
Aron Wiederkehr
af7f02d38a fixed missing middleware folder 2025-04-30 12:26:51 +02:00
Aron Wiederkehr
c7aa8c0c80 feat: Implement JWT authentication for admin access and secure socket connections 2025-04-30 11:34:36 +02:00
Aron Wiederkehr
01750b3137 style: Refactor ChannelList component for consistency and readability 2025-04-28 12:43:45 +02:00
Aron Wiederkehr
8d0032ad59 feat: Add admin mode functionality with login and status checks
- Implemented admin mode configuration in docker-compose.yml
- Added AdminContext and AdminModal components for managing admin state
- Integrated admin login functionality in AuthController
- Updated App component to handle admin status and modal display
- Enhanced ChannelList, ChannelModal, and TvPlaylistModal to support admin features
- Added sensitive information handling in ChannelModal and TvPlaylistModal
- Modified SocketService methods to include admin checks for channel and playlist operations
2025-04-28 11:16:35 +02:00
antebrl
b3e3870c89 Merge branch 'main' of https://github.com/antebrl/IPTV-StreamHub 2025-04-18 21:09:33 +02:00
antebrl
2962da2f9a fix: improve proxy mode redirect handling 2025-04-18 21:09:28 +02:00
Ante Brähler
c2d181d833 docs: guidance on xtream codes playlists 2025-04-18 15:39:15 +02:00
antebrl
fb1abd294e fix: proxy allow redirect 2025-04-18 15:19:23 +02:00
antebrl
f19c674409 Merge branch 'main' of https://github.com/antebrl/IPTV-StreamHub 2025-04-17 00:07:42 +02:00
antebrl
2909a15528 feat: more readable playlist reponse 2025-04-17 00:07:39 +02:00
Ante Brähler
11dedc6614 docs: update daddylive guide 2025-04-16 23:13:35 +02:00
Ante Brähler
3787a7730a docs: introduction of the NEW hosted Solution 2025-04-13 15:10:04 +02:00
antebrl
27a4b216ec Merge branch 'main' of https://github.com/antebrl/IPTV-Restream 2025-04-13 12:50:39 +00:00
antebrl
e1d2ae8d7c fix: remove default channels, which are not working 2025-04-13 12:50:37 +00:00
antebrl
42de5e7aa3 chore: update image versions 2025-03-21 18:57:31 +01:00
Ante Brähler
7ab022b4ab fix: group of CURRENT RESTREAM channel 2025-03-19 01:14:58 +01:00
antebrl
6547095095 fix: add daddylive demo channels 2025-03-15 18:25:35 +00:00
antebrl
7a1740e8bc docs: moveonjoy provider 2025-03-15 14:18:31 +00:00
antebrl
ac78f53cdd build: set restart policy 2025-03-11 14:24:38 +00:00
antebrl
d2d03bb972 Merge branch 'main' of https://github.com/antebrl/IPTV-Restream 2025-03-05 19:49:12 +00:00
antebrl
399b51e93c fix: update dd6 channel urls 2025-03-05 19:49:08 +00:00
Ante Brähler
80795dc5dc docs: test server status badge 2025-03-04 21:32:23 +01:00
Ante Brähler
bc738cc12d docs: add test server status 2025-03-04 21:22:33 +01:00
antebrl
a8856ace0c config: default no sync enabled 2025-03-03 09:15:46 +00:00
Ante Brähler
bac5999cfd docs: add dd6 instructions 2025-02-23 23:51:58 +01:00
antebrl
0a1f434d7a Merge branch 'main' of https://github.com/antebrl/IPTV-Restream 2025-02-20 23:13:27 +00:00
antebrl
be044271e4 fix: update dd6 base url 2025-02-20 23:13:23 +00:00
Ante Brähler
5dcb1050e7 Create CONTRIBUTING.md 2025-02-03 12:14:11 +01:00
Ante Brähler
8d9c1f41eb Create LICENSE 2025-02-02 21:08:11 +01:00
antebrl
320b716664 feat: add clear channels api endpoint 2025-01-22 23:19:18 +00:00
antebrl
540914f411 fix: set CORS header for api 2025-01-19 17:20:48 +00:00
Ante Brähler
00bb05c71b Merge pull request #56 from antebrl/24-central-redirect-m3u8-link-for-other-iptv-players
24 central redirect m3u8 link for other iptv players
2025-01-19 15:21:54 +01:00
antebrl
ca7c1dcdb5 docs: add tv playlist description 2025-01-19 14:21:37 +00:00
antebrl
a5d42685ae feat: add frontend TvPlaylistModal 2025-01-19 14:04:45 +00:00
antebrl
32e926b978 fix: channelModal scroll wheel 2025-01-19 13:48:49 +00:00
antebrl
483412f50e feat: add tv playlist endpoint 2025-01-19 13:20:12 +00:00
antebrl
b1d53f0051 feat: add a central proxied stream and playlist 2025-01-19 02:32:35 +00:00
Ante Brähler
b65b5f1a8f Merge pull request #55 from antebrl/50-reload-playlists-iteratively
50 reload playlists iteratively
2025-01-18 18:00:53 +01:00
antebrl
ee247bc5b5 refactor: better storage of auto-update playlists 2025-01-18 01:00:03 +00:00
antebrl
669d08d6c6 fix: playlistUpdate setting in channel class 2025-01-18 00:22:16 +00:00
antebrl
456f97e1c5 feat: auto update playlist option 2025-01-18 00:14:55 +00:00
antebrl
96e4ab927b fix: add streamed headers automatically 2025-01-17 10:43:31 +00:00
Ante Brähler
53f01f4ddf docs: update free playlist headers 2025-01-17 11:43:07 +01:00
antebrl
383beac9c2 feat: streamed sports api compatability 2025-01-17 10:28:12 +00:00
antebrl
11df48fa3d fix: edit session decrypt url 2025-01-15 00:41:00 +00:00
Ante Brähler
9fb3ba1768 Merge pull request #54 from antebrl/53-allow-to-use-m3u-playlist-text-instead-of-link
53 allow to use m3u playlist text instead of link
2025-01-14 00:13:05 +01:00
antebrl
8a919768f8 fix: add error handling for playlist request 2025-01-13 22:54:40 +00:00
antebrl
ddd9a797a2 refactor: extract functions and avoid duplicates in PlaylistSocketHandler 2025-01-13 22:43:09 +00:00
antebrl
34e195e2bb fix: backend m3u playlist saving 2025-01-13 21:50:51 +00:00
Ante Brähler
a13c04603d feat: backend integration of playlist m3u text 2025-01-13 16:13:52 +00:00
Ante Brähler
2c92d75b03 feat: add frontend m3u text selection 2025-01-13 14:20:30 +00:00
antebrl
0d564ffe59 fix: don't throw error 2025-01-13 01:05:50 +00:00
antebrl
355c44241c chore: ignore second deployment 2025-01-12 23:50:20 +00:00
Ante Brähler
5772c85d53 Merge pull request #52 from antebrl/40-store-channels-persistent-in-docker-volume
feat: store channels in filesystem
2025-01-12 20:40:28 +01:00
antebrl
3c7cf42a8d feat: store channels in filesystem 2025-01-12 19:39:43 +00:00
Ante Brähler
36a7ebd626 docs: add server distance notice 2025-01-11 02:07:42 +01:00
Ante Brähler
624586ef62 Merge branch 'main' of https://github.com/antebrl/IPTV-StreamHub 2025-01-11 02:05:06 +01:00
Ante Brähler
a3d36493ec chore: dump hls.js version to support hevc codec 2025-01-11 02:05:02 +01:00
antebrl
8fbbf73ff7 fix: current channel deletion in restream mode 2025-01-10 01:30:27 +00:00
antebrl
68060097c5 fix: catch wrong source error in proxy mode 2025-01-10 00:30:17 +00:00
Ante Brähler
d79e90015f Update release-build.yml 2025-01-09 00:45:59 +01:00
Ante Brähler
116cabcb55 Update README.md 2025-01-08 16:30:13 +01:00
Ante Brähler
588c09646b Merge pull request #51 from antebrl/45-backend-suggestions
45 backend suggestions
2025-01-07 20:09:26 +01:00
Ante Brähler
780c85d052 Update README.md 2025-01-07 20:08:34 +01:00
Ante Brähler
d19d0dda41 Update README.md 2025-01-07 20:01:28 +01:00
Ante Brähler
d24c671892 Update README.md 2025-01-07 19:58:02 +01:00
Ante Brähler
b0a3f5a3a1 Update README.md 2025-01-07 19:47:38 +01:00
Ante Brähler
78ce8f8620 Update README.md 2025-01-07 19:04:25 +01:00
antebrl
deebc1f509 docs: add example playlists 2025-01-07 18:03:11 +00:00
antebrl
de783d3fc1 feat: proxy session management 2025-01-07 17:17:26 +00:00
antebrl
5820a8b40a fix: remove frontend session management 2025-01-07 17:02:02 +00:00
antebrl
3932b0cc33 fix: restream session management adjustments for su 2025-01-07 17:01:32 +00:00
antebrl
06b04c57b6 fix: channel api int parsing 2025-01-07 15:26:02 +00:00
Ante Brähler
8fb36274df fix: http headers already sent (#44) 2025-01-06 23:36:49 +01:00
Ante Brähler
2752747121 Merge pull request #49 from antebrl/42-playlist-group-selection
42 playlist group selection
2025-01-06 19:52:41 +01:00
Ante Brähler
2677924c0e feat: channel group category filtering 2025-01-06 19:51:41 +01:00
Ante Brähler
500455c093 feat: add playlist name 2025-01-06 17:45:10 +01:00
Ante Brähler
953f54657c feat: frontend playlist selection 2025-01-06 17:13:25 +01:00
Ante Brähler
460ef35b62 Merge pull request #48 from antebrl/25-iptv-session-management
25 iptv session management
2025-01-06 03:02:52 +01:00
Ante Brähler
7fc0f3f6bf refactor: session handling in proxy mode in frontend 2025-01-06 02:47:00 +01:00
Ante Brähler
65711df828 chore: dump docker image version to v2.1 2025-01-02 21:41:09 +01:00
antebrl
a6e6927fdd Merge branch 'main' of https://github.com/antebrl/IPTV-Restream 2025-01-02 20:26:03 +00:00
antebrl
e9ea6ca16f chore: better naming of docker containers 2025-01-02 20:24:22 +00:00
Ante Brähler
f4bb7bc85e Merge pull request #46 from antebrl/44-backend-crashes-in-proxy-mode
44 backend crashes in proxy mode
2025-01-02 21:08:24 +01:00
antebrl
59e93fb629 fix: proxy mode with on different port not working 2025-01-02 19:31:57 +00:00
antebrl
f2f86fe88d fix: prevent proxy backend from crashing 2024-12-31 13:20:30 +00:00
Ante Brähler
ac0422ef94 feat: frontend session management 2024-12-28 00:21:06 +01:00
Ante Brähler
c3bcfb4378 feat: add channel filtering functionality to api 2024-12-27 19:43:36 +01:00
Ante Brähler
4854f767a5 fix: session management backend bug fixes 2024-12-27 17:38:05 +01:00
Ante Brähler
672890974d feat: initial session management 2024-12-26 21:20:19 +01:00
Ante Brähler
edb6c53a0f update label 2024-12-25 15:47:23 +01:00
Ante Brähler
fbffea81e8 ci: automatic update ghcr & push nginx 2024-12-25 15:37:16 +01:00
Ante Brähler
127728f2c5 docs: restream mode usage statement 2024-12-24 23:32:24 +01:00
antebrl
36181e2bfe feat: update prod configuration 2024-12-23 02:36:49 +00:00
Ante Brähler
6ada26a80b Fix README.md 2024-12-23 02:18:17 +01:00
Ante Brähler
3799d7e23c Merge pull request #41 from antebrl/39-proxy-mode
39 proxy mode
2024-12-23 02:17:12 +01:00
Ante Brähler
0568f00cee Update README.md 2024-12-23 02:14:15 +01:00
Ante Brähler
01ab92606d docs: fix note alert 2024-12-23 02:12:18 +01:00
Ante Brähler
7f4bfd94c1 docs: add mode description 2024-12-23 02:08:27 +01:00
Ante Brähler
edcbbd8789 fix: playlist short path not rewritten 2024-12-23 00:57:25 +01:00
Ante Brähler
b151a406b3 feat!: integrate proxy mode into application 2024-12-23 00:16:48 +01:00
Ante Brähler
7adc220ce3 refactor: extract helper class 2024-12-22 23:08:22 +01:00
Ante Brähler
e69e55911c fix: m3u8 playlist parsing 2024-12-22 22:54:52 +01:00
Ante Brähler
1f03c298ef feat: url, channelId and headers query param options 2024-12-22 18:49:26 +01:00
Ante Brähler
070fb5d48a feat: initial proxy backend 2024-12-22 18:12:50 +01:00
antebrl
84776cf744 fix: reconnect to sourceStream ffmpeg 2024-12-21 23:15:16 +00:00
antebrl
9fba7a49f1 fix: restream message, if not synched 2024-12-21 12:12:49 +00:00
antebrl
3701d5bd43 feat: restream starting notification duration 2024-12-20 21:02:22 +00:00
Ante Brähler
2ce5fd2ca4 Merge branch 'main' of https://github.com/antebrl/IPTV-StreamHub 2024-12-20 19:36:11 +01:00
Ante Brähler
4efa722c4b fix: restart ffmpeg restream whenever it crashes 2024-12-20 19:36:03 +01:00
Ante Brähler
a836c64871 Merge pull request #33 from antebrl/32-edit-and-delete-playlist
32 edit and delete playlist
2024-12-20 00:50:34 +01:00
Ante Brähler
f727477d47 feat: add playlist update and delete functionality 2024-12-20 00:49:41 +01:00
Ante Brähler
8973d9bd5d docs: update and add faq 2024-12-20 00:48:14 +01:00
Ante Brähler
416755d2d6 Create FUNDING.yml 2024-12-19 02:08:55 +01:00
Ante Brähler
7eb8051021 Merge pull request #31 from antebrl/25-iptv-session-management
push production build
2024-12-18 23:24:56 +01:00
Ante Brähler
90f770142e build: prebuild docker images 2024-12-18 23:24:23 +01:00
antebrl
712c789681 build: push production build 2024-12-18 21:44:28 +00:00
antebrl
e68565f878 docs: update readme 2024-12-17 21:22:20 +00:00
Ante Brähler
8185d8b54e Merge pull request #28 from antebrl/add-m3u-playlist-support-1
Add m3u playlist support
2024-12-16 16:43:53 +01:00
antebrl
c05d94189f feat: channel deletion notification 2024-12-16 15:39:36 +00:00
antebrl
d3e496628a feat: show current channel info 2024-12-16 14:57:03 +00:00
antebrl
55dd8efbb2 fix: channel delete and update 2024-12-16 14:10:14 +00:00
antebrl
77a0089e62 feat: improve error handling in addChannel and parsePlaylist methods 2024-12-16 01:50:39 +00:00
antebrl
121aff4c1e feat: enhance ChannelModal with toast notifications and required fields 2024-12-16 01:35:39 +00:00
antebrl
20f3a4a5b5 feat: truncate long channel names in ChannelList 2024-12-16 01:13:34 +00:00
antebrl
b51d066792 feat: add m3u playlists 2024-12-16 00:59:00 +00:00
antebrl
f931e93355 refactor: remove unused 2024-12-15 21:03:07 +00:00
Ante Brähler
65375b585a 2024-12-15 21:54:15 +01:00
Ante Brähler
cd9a960c37 Add m3u playlist support 2024-12-15 21:32:01 +01:00
Ante Brähler
ee4786b991 Merge pull request #26 from antebrl/update-delete-channels-ee1
feat: implement channel management features (add, update, delete)
2024-12-12 00:20:23 +01:00
Ante Brähler
61f36b5782 fix: reloading only if necessary 2024-12-11 23:19:20 +00:00
Ante Brähler
57c6f6eb80 feat: implement channel management features (add, update, delete) 2024-12-11 22:54:42 +00:00
Ante Brähler
fe3108013c Merge pull request #23 from antebrl/22-restream-notifications
feat: stream notifications
2024-12-07 18:55:52 +01:00
antebrl
30e6caa040 feat: stream notifications 2024-12-07 17:54:22 +00:00
antebrl
b3a82b17b1 fix: allow to add equal channel with different restream setting 2024-12-05 23:42:42 +00:00
Ante Brähler
d28b40d126 Merge pull request #21 from antebrl/20-disable-synchronization-setting
20 disable synchronization setting
2024-12-04 14:59:46 +01:00
antebrl
14e9519d29 feat: store synchronization setting in browser 2024-12-04 13:58:57 +00:00
antebrl
d99adb25a2 feat: sync setting 2024-12-04 12:17:46 +00:00
Ante Brähler
49dc84463b Merge pull request #19 from antebrl/18-custom-http-header-restreaming
18 custom http header restreaming
2024-12-04 00:44:10 +01:00
antebrl
41af2a019a fix: ffmpeg headers 2024-12-03 23:36:19 +00:00
antebrl
bd42f1374d feat: backend custom http headers implementation 2024-12-03 22:58:06 +00:00
antebrl
33453a3bf7 feat: frontend custom http headers 2024-12-03 22:56:51 +00:00
antebrl
7031aad92a feat: some initial testing channels 2024-12-02 16:53:17 +00:00
Ante Brähler
b1f0b78214 Merge pull request #17 from antebrl/16-player-laggs-when-loading-new-channel
16 player laggs when loading new channel
2024-12-02 16:43:17 +01:00
antebrl
eff26a211b fix: reload manifest until enough playback time 2024-12-02 15:25:19 +00:00
antebrl
ac2b9fcee0 feat: add optional synchronization settings and set upper limit for adjustment-rate 2024-12-02 14:18:56 +00:00
antebrl
abb17f3ee2 refactor: improve channel storage management and streaming logic 2024-12-02 13:57:38 +00:00
antebrl
86a09a90b3 fix: naming of playlist according to channelId 2024-12-02 10:17:48 +00:00
67 changed files with 4513 additions and 483 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: antebrl

View File

@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: core feature
labels: feature request
assignees: ''
---

29
.github/ISSUE_TEMPLATE/help_wanted.md vendored Normal file
View File

@@ -0,0 +1,29 @@
---
name: Help Wanted
about: Request help from the community
title: "[HELP WANTED] "
labels: "help wanted"
assignees: ""
---
## Help Wanted
**What do you need help with?**
<!-- A clear and concise description of what you need assistance with. -->
**What have you tried?**
<!-- Describe what you've already attempted to solve this issue. -->
**Expected outcome**
<!-- What are you trying to achieve? -->
**Current behavior**
<!-- What is currently happening instead? -->
**Additional context**
<!-- Add any other context, screenshots, or information that might help others assist you. -->

17
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View File

@@ -0,0 +1,17 @@
---
name: Question
about: Ask a question about the project
title: "[QUESTION] "
labels: question
assignees: ""
---
## Question
**Short Summary**
<!-- A clear and concise description of your question. -->
**Detailed Explanation**
<!-- Please provide any relevant context that might help us answer your question (e.g., what you're trying to accomplish, what you've already tried, etc.). -->

44
.github/workflows/release-build.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Build and Publish Docker Images
on:
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Backend Image
run: |
docker build -t ghcr.io/antebrl/iptv-restream/backend:${{ github.event.release.tag_name }} ./backend
docker tag ghcr.io/antebrl/iptv-restream/backend:${{ github.event.release.tag_name }} ghcr.io/antebrl/iptv-restream/backend:latest
docker push ghcr.io/antebrl/iptv-restream/backend:${{ github.event.release.tag_name }}
docker push ghcr.io/antebrl/iptv-restream/backend:latest
- name: Build and Push Frontend Image
run: |
docker build --build-arg VITE_BACKEND_STREAMS_PATH=/streams/ --build-arg VITE_STREAM_DELAY=18 -t ghcr.io/antebrl/iptv-restream/frontend:${{ github.event.release.tag_name }} ./frontend
docker tag ghcr.io/antebrl/iptv-restream/frontend:${{ github.event.release.tag_name }} ghcr.io/antebrl/iptv-restream/frontend:latest
docker push ghcr.io/antebrl/iptv-restream/frontend:${{ github.event.release.tag_name }}
docker push ghcr.io/antebrl/iptv-restream/frontend:latest
- name: Build and Push Nginx Image
run: |
docker build -t ghcr.io/antebrl/iptv-restream/nginx:${{ github.event.release.tag_name }} ./deployment/nginx
docker tag ghcr.io/antebrl/iptv-restream/nginx:${{ github.event.release.tag_name }} ghcr.io/antebrl/iptv-restream/nginx:latest
docker push ghcr.io/antebrl/iptv-restream/nginx:${{ github.event.release.tag_name }}
docker push ghcr.io/antebrl/iptv-restream/nginx:latest

9
.gitignore vendored
View File

@@ -1,6 +1,12 @@
# Node Modules
node_modules/
deployment/data/
deployment/letsencrypt/
deployment/darmstadt-localplayer/
daddylive-cloudflare/*
/build/
/dist/
@@ -37,3 +43,6 @@ Thumbs.db
*.zip
*.tar.gz
*.rar
.devcontainer/

32
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,32 @@
Contributions, feedback, and suggestions are all warmly welcome! Whether you're helping fix a bug, suggesting design improvements, or adding a new feature, your input is valued.
## Recommendations
1. Commit Descriptive Messages
Provide clear and concise commit messages that describe the purpose of the changes made in the commit.
2. Stage Relevant Changes
Only stage and commit changes that are relevant to the current task or issue being addressed. Avoid committing unrelated changes.
3. Test Locally
Test your changes locally before committing to ensure they function as intended and do not introduce errors or bugs.
4. Pull Before Push
Always pull the latest changes from the remote repository before pushing your changes to avoid conflicts and ensure your local repository is up-to-date.
5. Resolve Conflicts Promptly
If conflicts arise during the pull or merge process, resolve them promptly and communicate with team members if necessary.
6. Follow Coding Standards
Maintain coding standards and style guidelines established for the project or organization. Ensure consistency in formatting and code structure.
7. Review Changes
Review your changes before committing to catch any mistakes or overlooked issues. Consider doing a code review.
8. Include Relevant Documentation
Update relevant documentation, such as README files or inline comments, to reflect any changes made and provide context for future developers.
9. Avoid Pushing Sensitive Information
Be cautious not to push sensitive information, such as passwords, API keys, or confidential data, to the repository. Utilize environment variables or configuration files for such information.
10. Monitor Continuous Integration (CI) Status
Ensure that any automated tests or CI pipelines associated with the repository pass successfully before pushing changes. Fix failing tests or build failures promptly.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ante Brähler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

115
README.md
View File

@@ -1,33 +1,20 @@
# IPTV StreamHub
A simple IPTV `restream` and `synchronization` application with web frontend.
## ✨ Features
**Restreaming** - Proxy your iptv stream through the backend.
**Synchronization** - The playback of the stream is perfectly synchronized for all viewers.
**Channels** - Add multiple iptv streams, you can switch between.
**Live chat** - chat with other viewers with randomized profile.
A simple IPTV `restream` and `synchronization` (watch2gether) application with `web` frontend. Share your iptv playlist and watch it together with your friends.
## 💡Use Cases
- Connect with multiple Devices to 1 IPTV Stream, if your provider limits current devices.
- Proxy all Requests through one IP.
- Helps with CORS issues.
- Synchronize IPTV streaming with multiple devices: Synchronized playback and channel selection.
- Share your iptv and watch together with your friends.
## 🛠️ Architecture
### `Frontend`
A simple React webpage that can stream iptv streams in hls-format. Provides synchronized playback by using a constant delay. Also supports multiple IPTV streams (channel selection) and a chat if using together with the backend.
### `Backend`
A simple NodeJS web server that retrieves your IPTV stream, caches it, and converts it into an HLS stream, making it accessible via the web. Also supports multiple IPTV streams (channel selection).
- [x] IPTV Web player supporting multiple playlists at once.
- [x] Connect with **multiple Devices** to 1 IPTV Stream, if your provider limits current streaming devices (restream mode).
- [x] Proxy all Requests through **one IP** (proxy and restream mode).
- [x] Helps with CORS issues.
- [x] **Synchronize** IPTV streaming with multiple devices: Synchronized playback and channel selection for perfect Watch2Gether.
- [x] **Share your iptv access** without revealing your actual stream-url (privacy-mode) and watch together with your friends.
## ✨ Features
**IPTV Player** - IPTV web player with support for any other iptv players by exposing the playlist.
**Restream / Proxy** - Proxy your iptv streams through the backend. <br>
**Synchronization** - The selection and playback of the stream is perfectly synchronized for all viewers. <br>
**Channels** - Add multiple iptv streams and playlists, you can switch between. <br>
**Live chat** - chat with other viewers with a randomized profile.
## 🚀 Run
@@ -45,22 +32,78 @@ docker compose up -d
```
Open http://localhost
You can configure the project by editing the [docker-compose](docker-compose.yml).
### Run components seperately
If you only need the **restream** functionality and want to use another iptv player (e.g. VLC), you may only run the [backend](/backend/README.md).
<br>
If you only need the **synchronization** functionality, you may only run the [frontend](/frontend/README.md).
> [!IMPORTANT]
> If a channel/playlist won't work, please try with `proxy` or `restream` mode. This fixes most of the problems! See also [Channel Mode](#channel-mode).
>
> If you're using an **Xtream Codes** playlist (format: `/get.php?username=xxx&password=xxx&type=xxx&output=xxx`), try the following options:
> - Use **proxy mode** with HLS output: Use `&type=m3u_plus&output=hls` in your playlist URL.
> - Use **restream mode** with MPEG-TS output: Use `&type=m3u_plus&output=ts` to your playlist URL.
>
> If your playlist is a plain HTTP link or has CORS issues, you must use **proxy** or **restream mode** to ensure compatibility in the web.
Be aware, that this'll require additional configuration/adaption and won't be officially supported. It is recommended to [run the whole project as once](#run-with-docker).
There is also [documentation for ADVANCED DEPLOYMENT](/deployment/README.md):
- Configuration options (Admin mode).
- Deploy from container registry and without cloning and building.
- Deploy together with nginx proxy manager for automatic ssl handling.
## Preview
## 🆓 Free compatible playlists
These are some tested playlists as an example. Use your own iptv playlist for the best quality!
- [Free TV Channels](https://github.com/iptv-org/iptv): Huge collection of free tv-channels. One playlist for every country.
## 🖼️ Preview
![Frontend Preview](/frontend/ressources/frontend-preview.png)
![Add channel](/frontend/ressources/add-channel.png)
## ⚙️ Settings
### Channel Mode
#### `Direct`
Directly uses the source stream. Won't work with most of the streams, because of CORS, IP/Device restrictions. Is also incompatible with custom headers and privacy mode.
#### `Proxy` (Preffered)
The stream requests are proxied through the backend. Allows to set custom headers and bypass CORS. This mode is preffered. Only switch to restream mode, if proxy mode won't work for your stream or if you have synchronization issues.
#### `Restream`
The backend service caches the source stream (with ffmpeg) and restreams it. Can help with hard device restrictions of your provider or synchroization problems (when your iptv channels have no programDateTime). But it can lead to longer initial loading times and performance issues after time.
## FAQ & Common Mistakes
Which streaming mode should I choose for the channel?
> Generally: You should try with direct mode first, switch to proxy mode if it doesn't work and switch to restream mode if this also doesn't work.
>
> Proxy mode is most likely the mode, you will use! You will need restream mode especially when your iptv playlist has no programDateTime set and you want to have playback synchronization.
---
How can I use the channels on any other iptv player (e.g. on TV)?
> Please click on the 📺 (TV-button) in the top-right in the frontend. There you'll find the playlist you have to use in any other iptv player.
> This playlist contains all your channels and one **CURRENT_CHANNEL**, which forwards the content of the currently played channel.
> If this playlist does not work, please check if the base-url of the channels in the playlist is correct and set the `BACKEND_URL` in the `docker-compose.yml` if not.
---
My playlist only supports xtream codes api!
> [IPTV playlist browser](https://github.com/PhunkyBob/iptv_playlist_browser) allows you to export a m3u playlist from your xtream codes account, and let's you select single channels or the whole playlist. Official xstreams-code integration is planned!
---
Error: `Bind for 0.0.0.0:80 failed: port is already allocated`
> To fix this, change the [port mapping in the docker-compose](docker-compose.yml#L40) to `X:80` e.g. `8080:80`. Make also sure that port X is open in the firewall configuration if you want to expose the application.
---
Is it possible to run components seperately, if I only need the frontend OR backend?
> If you only need the **restream** functionality and want to use another iptv player (e.g. VLC), you may only run the [backend](/backend/README.md).
> <br>
> If you only need the **synchronization** functionality, you may only run the [frontend](/frontend/README.md).
>
> Be aware, that this'll require additional configuration/adaption and won't be officially supported. It is recommended to [run the whole project as once](#run-with-docker-preferred).
---
Is there a option to limit access of channel management?
> Yes, you can enable [**Admin Mode**](/deployment/README.md#admin-mode) in the configuration to restrict channel management to authenticated administrators only.
## Contribute & Contact
Feel free to open discussions and issues for any type of requests. Don't hesitate to contact me, if you have any problems with the setup.

View File

@@ -1,11 +1,35 @@
# Frontend
# Backend
A simple NodeJS web server that is able to proxy and restream your any iptv stream/playlist and manage multiple playlists.
## 🚀 Run
It is strongly advised to [use the frontend together with the backend](../deployment/README.md).
If you still want to use it standalone, consider these options:
### With Docker (Preferred)
In this directory:
```bash
docker build -t iptv_restream_backend
```
```bash
docker run -d \
-v {streams_directory}:/streams \
-e STORAGE_PATH=/streams \
iptv_restream_backend
```
make sure that you have created a directory for the streams storage:
e.g. create `/streams` and replace `{streams_directory}` with it.
### Bare metal
Setup a `.env` file or
equivalent environment variables:
```env
DEFAULT_CHANNEL_URL=https://mcdn.daserste.de/daserste/de/master.m3u8
STORAGE_PATH=/mnt/streams/recordings
```
@@ -20,14 +44,40 @@ Be aware, that this application is designed for Linux systems!
To use together with the frontend, [run with docker](../README.md#run-with-docker-preferred).
## 🛠️ Architecture
## 🛠️ Endpoints
### API
- Endpoints to add a channel, get all channels, get selected channel and set selected channel
#### [ChannelController](./controllers/ChannelController.js)
### WebSockets
- GET: `/api/channels/:channelId` and `/api/channels` to get information about the registered channels.
- GET: `/api/channels/current` to get the currently playing channel.
- PUT: `api/channels/:channelId` to update a channel.
- DELETE: `api/channels/:channelId` to delete a channel.
- POST: `api/channels` to create a new channel.
#### [ProxyController](./controllers/ProxyController.js)
- `/proxy/channel` to get the M3U File of the current channel
- `/proxy/segment` and `/proxy/key` will be used by the iptv player directly
#### Restream
- `/streams/{currentChannelId}/{currentChannelId}.m3u` to access the current restream.
### WebSocket
- `channel-added` and `channel-selected` events will be send to all connected clients
- chat messages: `send-chat-message` and `chat-message`
- users: `user-connected` and `user-disconnected`
## Usage without the frontend (with other iptv player)
You can use all the channels with any other IPTV player. The backend exposes a **M3U Playlist** on `http://your-domain/api/channels/playlist`. You can also find it by clicking on the TV-button on the top right in the frontend!
If this playlist does not work, please check if the base-url of the channels in the playlist is correct and set the `BACKEND_URL` in the `docker-compose.yml` if not.
This playlist contains all your channels and one **CURRENT_CHANNEL**, which forwards the content of the currently played channel.
To modify the channel list, you can use the frontend or the [api](#channelcontroller).
> [!NOTE]
> These options are only tested with VLC media player as other iptv player. Use them at your own risk. Only for the usage together with the frontend will be support provided.

View File

@@ -0,0 +1,71 @@
require("dotenv").config();
const authService = require("../services/auth/AuthService");
module.exports = {
adminLogin(req, res) {
if (!authService.isAdminEnabled()) {
return res.status(403).json({
success: false,
message: "Admin mode is disabled on this server",
});
}
const { password } = req.body;
if (!password) {
return res.status(400).json({
success: false,
message: "Password is required",
});
}
if (authService.verifyAdminPassword(password)) {
const token = authService.generateAdminToken();
return res.json({
success: true,
token,
});
} else {
return res.status(401).json({
success: false,
message: "Invalid password",
});
}
},
checkAdminStatus(req, res) {
res.json({
enabled: authService.isAdminEnabled(),
channelSelectionRequiresAdmin: authService.channelSelectionRequiresAdmin(),
});
},
verifyToken(req, res, next) {
// If admin mode is disabled, allow all requests (skip authentication)
if (!authService.isAdminEnabled()) {
req.user = { isAdmin: false };
return next();
}
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
return res.status(401).json({
success: false,
message: "Access denied. No token provided.",
});
}
const decoded = authService.verifyToken(token);
if (!decoded) {
return res.status(401).json({
success: false,
message: "Invalid token.",
});
}
req.user = decoded;
next();
},
};

View File

@@ -0,0 +1,149 @@
const request = require('request');
const ChannelService = require('../services/ChannelService');
const ProxyHelperService = require('../services/proxy/ProxyHelperService');
const SessionFactory = require('../services/session/SessionFactory');
const Path = require('path');
const fs = require('fs');
const STORAGE_PATH = process.env.STORAGE_PATH;
const BACKEND_URL = process.env.BACKEND_URL;
function fetchM3u8(res, targetUrl, headers) {
console.log('Proxy playlist request to:', targetUrl);
try {
request(ProxyHelperService.getRequestOptions(targetUrl, headers), (error, response, body) => {
if (error) {
console.error('Request error:', error);
if (!res.headersSent) {
return res.status(500).json({ error: 'Failed to fetch m3u8 file' });
}
return;
}
try {
const proxyBaseUrl = '/proxy/';
const rewrittenBody = ProxyHelperService.rewriteUrls(body, proxyBaseUrl, headers, targetUrl).join('\n');
if(rewrittenBody.indexOf('channel?url=') !== -1) {
const regex = /channel\?url=([^&\s]+)/;
const match = rewrittenBody.match(regex);
const channelUrl = decodeURIComponent(match[1]);
return fetchM3u8(res, channelUrl, headers);
}
const updatedM3u8 = rewrittenBody.replace(/(#EXTINF.*)/, '#EXT-X-DISCONTINUITY\n$1');
return res.send(updatedM3u8);
} catch (e) {
console.error('Failed to rewrite URLs:', e);
return res.status(500).json({ error: 'Failed to parse m3uo file. Not a valid HLS stream.' });
}
//res.set('Content-Type', 'application/vnd.apple.mpegurl');
}).on('error', (err) => {
console.error('Unhandled error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
});
} catch (e) {
console.error('Failed to proxy request:', e);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
}
}
module.exports = {
async currentChannel(req, res) {
const channel = ChannelService.getCurrentChannel();
res.set('Access-Control-Allow-Origin', '*');
if(channel.restream()) {
const path = Path.resolve(`${STORAGE_PATH}${channel.id}/${channel.id}.m3u8`);
if (fs.existsSync(path)) {
try {
const m3u8Data = fs.readFileSync(path, 'utf-8');
let discontinuityAdded = false;
const updatedM3u8 = m3u8Data
.split('\n')
.map((line, index, lines) => {
// Füge #EXT-X-DISCONTINUITY vor der ersten #EXTINF hinzu
if (!discontinuityAdded && line.startsWith('#EXTINF')) {
discontinuityAdded = true;
return `#EXT-X-DISCONTINUITY\n${line}`;
}
// Passe die .ts-Dateipfade an
if (line.endsWith('.ts')) {
return `${STORAGE_PATH}${channel.id}/${line}`;
}
return line;
})
.join('\n');
return res.send(updatedM3u8);
} catch (err) {
console.error('Error loading m3u8 data from fs:', err);
res.status(500).json({ error: 'Failed to load m3u8 data from filesystem.' });
}
}
//add platzhalter
return res.send('No m3u8 data found.');
} else {
// Direct/Proxy Mode
// -> Fetch the m3u8 file from the channel URL
let targetUrl = channel.url;
const sessionProvider = SessionFactory.getSessionProvider(channel);
if(sessionProvider) {
await sessionProvider.createSession();
targetUrl = channel.sessionUrl;
}
let headers = undefined;
if(channel.headers && channel.headers.length > 0) {
headers = Buffer.from(JSON.stringify(channel.headers)).toString('base64');
}
fetchM3u8(res, targetUrl, headers);
}
},
playlist(req, res) {
const backendBaseUrl = BACKEND_URL ? BACKEND_URL : `${req.headers['x-forwarded-proto'] ?? 'http'}://${req.get('Host')}:${req.headers['x-forwarded-port'] ?? ''}`;
let playlistStr = `#EXTM3U
#EXTINF:-1 tvg-name="CURRENT RESTREAM" tvg-logo="https://cdn-icons-png.freepik.com/512/9294/9294560.png" group-title="StreamHub",CURRENT RESTREAM
${backendBaseUrl}/proxy/current \n`;
//TODO: dynamically add channels from ChannelService
const channels = ChannelService.getChannels();
for(const channel of channels) {
let restreamMode = undefined;
if(channel.restream()) {
restreamMode = channel.headers && channel.headers.length > 0 ? 'proxy' : 'direct';
}
playlistStr += `\n#EXTINF:-1 tvg-name="${channel.name}" tvg-logo="${channel.avatar}" group-title="${channel.group ?? ''}",${channel.name} \n`;
if(channel.mode === 'direct' || restreamMode === 'direct') {
playlistStr += channel.url;
} else {
let headers = undefined;
if(channel.headers && channel.headers.length > 0) {
headers = Buffer.from(JSON.stringify(channel.headers)).toString('base64');
}
playlistStr += `${backendBaseUrl}/proxy/channel?url=${encodeURIComponent(channel.url)}${headers ? `&headers=${headers}` : ''} \n`;
}
}
res.set('Content-Type', 'text/plain');
res.send(playlistStr);
}
};

View File

@@ -1,11 +1,61 @@
const ChannelService = require('../services/ChannelService');
module.exports = {
getChannels(req, res) {
res.json(ChannelService.getChannels());
const channels = ChannelService.getFilteredChannels(req.query);
res.json(channels);
},
getChannel(req, res) {
const { channelId } = req.params;
const channelIdInt = parseInt(channelId, 10);
const channel = ChannelService.getChannelById(channelIdInt);
if (channel) {
res.json(channel);
} else {
res.status(404).json({ error: 'Channel not found' });
}
},
getCurrentChannel(req, res) {
res.json(ChannelService.getCurrentChannel());
},
deleteChannel(req, res) {
try {
const { channelId } = req.params;
const channelIdInt = parseInt(channelId, 10);
ChannelService.deleteChannel(channelIdInt);
res.status(204).send();
} catch (error) {
res.status(500).json({ error: error.message });
}
},
async updateChannel(req, res) {
try {
const { channelId } = req.params;
const channelIdInt = parseInt(channelId, 10);
const updatedChannel = await ChannelService.updateChannel(channelIdInt, req.body);
res.json(updatedChannel);
} catch (error) {
res.status(500).json({ error: error.message });
}
},
addChannel(req, res) {
try {
//const { name, url, avatar, mode, headersJson, group, playlist } = req.body;
const newChannel = ChannelService.addChannel(req.body);
res.status(201).json(newChannel);
} catch (error) {
res.status(500).json({ error: error.message });
}
},
clearChannels(req, res) {
ChannelService.clearChannels();
res.status(204).send();
}
};

View File

@@ -0,0 +1,125 @@
const request = require('request');
const url = require('url');
const ChannelService = require('../services/ChannelService');
const ProxyHelperService = require('../services/proxy/ProxyHelperService');
const SessionFactory = require('../services/session/SessionFactory');
const proxyBaseUrl = '/proxy/';
module.exports = {
async channel(req, res) {
let { url: targetUrl, channelId, headers } = req.query;
if(!targetUrl) {
const channel = channelId ?
ChannelService.getChannelById(parseInt(channelId)) :
ChannelService.getCurrentChannel();
if (!channel) {
res.status(404).json({ error: 'Channel not found' });
return;
}
targetUrl = channel.url;
const sessionProvider = SessionFactory.getSessionProvider(channel);
if(sessionProvider) {
await sessionProvider.createSession();
targetUrl = channel.sessionUrl;
}
if(channel.headers && channel.headers.length > 0) {
headers = Buffer.from(JSON.stringify(channel.headers)).toString('base64');
}
}
console.log('Proxy playlist request to:', targetUrl);
res.set('Access-Control-Allow-Origin', '*');
try {
request({
...ProxyHelperService.getRequestOptions(targetUrl, headers),
followRedirect: false
}, (error, response, body) => {
if (error) {
if (!res.headersSent) {
return res.status(500).json({ error: 'Failed to fetch m3u8 file' });
}
console.error('Request error:', error);
return;
}
// invalid response
if (response.statusCode >= 400) {
if (!res.headersSent) {
res.status(response.statusCode);
}
return res.send(body);
}
//redirect response
if (response.statusCode >= 300) {
const redirectLocation = response.headers.location;
const absoluteUrl = url.resolve(targetUrl, redirectLocation);
const proxyRedirect = `channel/?url=${encodeURIComponent(absoluteUrl)}${headers ? `&headers=${headers}` : ''}`;
return res.redirect(response.statusCode, proxyRedirect);
}
try {
const responseUrl = response.request.href;
const rewrittenBody = ProxyHelperService.rewriteUrls(body, proxyBaseUrl, headers, responseUrl).join('\n');
res.send(rewrittenBody);
} catch (e) {
console.error('Failed to rewrite URLs:', e);
res.status(500).json({ error: 'Failed to parse m3uo file. Not a valid HLS stream.' });
}
//res.set('Content-Type', 'application/vnd.apple.mpegurl');
}).on('error', (err) => {
console.error('Unhandled error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
});
} catch (e) {
console.error('Failed to proxy request:', e);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
}
},
segment(req, res) {
let { url: targetUrl, headers } = req.query;
if (!targetUrl) {
res.status(400).json({ error: 'Missing url query parameter' });
return;
}
console.log('Proxy request to:', targetUrl);
res.set('Access-Control-Allow-Origin', '*');
req.pipe(
request(ProxyHelperService.getRequestOptions(targetUrl, headers))
.on('error', (err) => {
console.error('Proxy request error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Proxy request failed' });
}
return;
})
).pipe(res)
.on('error', (err) => {
console.error('Response stream error:', err);
});
},
key(req, res) {
module.exports.segment(req, res);
}
};

View File

@@ -1,11 +1,25 @@
class Channel {
static nextId = 0;
constructor(name, url, avatar, restream) {
constructor(name, url, avatar, mode, headers = [], group = null, playlist = null, playlistName = null, playlistUpdate = false) {
this.id = Channel.nextId++;
this.name = name;
this.url = url;
this.sessionUrl = null;
this.avatar = avatar;
this.restream = restream;
this.mode = mode;
this.headers = headers;
this.group = group;
this.playlist = playlist;
this.playlistName = playlistName;
this.playlistUpdate = playlistUpdate;
}
restream() {
return this.mode === 'restream';
}
static from(json){
return Object.assign(new Channel(), json);
}
}

View File

@@ -0,0 +1,16 @@
class Playlist {
static nextId = 0;
constructor(playlist, playlistName, mode, playlistUpdate, headers = []) {
this.headers = headers;
this.mode = mode;
this.playlist = playlist;
this.playlistName = playlistName;
this.playlistUpdate = playlistUpdate;
}
static from(json){
return Object.assign(new Playlist(), json);
}
}
module.exports = Playlist;

View File

@@ -10,8 +10,13 @@
"license": "ISC",
"dependencies": {
"child_process": "^1.0.2",
"crypto": "^1.0.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"iptv-playlist-parser": "^0.13.0",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"request": "^2.88.2",
"socket.io": "^4.8.1"
}
},
@@ -53,11 +58,60 @@
"node": ">= 0.6"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"engines": {
"node": "*"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@@ -66,6 +120,14 @@
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@@ -89,6 +151,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -115,11 +183,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
},
"node_modules/child_process": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz",
"integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -152,6 +236,11 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -164,6 +253,23 @@
"node": ">= 0.10"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in."
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -188,6 +294,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -216,6 +330,24 @@
"url": "https://dotenvx.com"
}
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -359,6 +491,29 @@
"node": ">= 0.10.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
]
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
@@ -376,6 +531,27 @@
"node": ">= 0.8"
}
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"engines": {
"node": "*"
}
},
"node_modules/form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -418,6 +594,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -429,6 +613,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"engines": {
"node": ">=4"
}
},
"node_modules/har-validator": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
@@ -488,6 +693,20 @@
"node": ">= 0.8"
}
},
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.3.7"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -512,6 +731,191 @@
"node": ">= 0.10"
}
},
"node_modules/iptv-playlist-parser": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/iptv-playlist-parser/-/iptv-playlist-parser-0.13.0.tgz",
"integrity": "sha512-As51+8A7AcFzV9Y8mt30TIbRkBn6l0TGuL9lIG2bPcqb+YYRVzfjsqqugz3eWbEmziEKEsLzexnqPSO7ZzQc0A==",
"dependencies": {
"is-valid-path": "^0.1.1",
"validator": "^13.7.0"
}
},
"node_modules/is-extglob": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
"integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
"integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==",
"dependencies": {
"is-extglob": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-invalid-path": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-0.1.0.tgz",
"integrity": "sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==",
"dependencies": {
"is-glob": "^2.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/is-valid-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz",
"integrity": "sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==",
"dependencies": {
"is-invalid-path": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="
},
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"license": "MIT",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -579,6 +983,33 @@
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-cron/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"engines": {
"node": "*"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -622,6 +1053,11 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w=="
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -634,6 +1070,25 @@
"node": ">= 0.10"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -670,6 +1125,45 @@
"node": ">= 0.8"
}
},
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.2",
"har-validator": "~5.1.3",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.5.0",
"tunnel-agent": "^0.6.0",
"uuid": "^3.3.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/request/node_modules/qs": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -694,6 +1188,18 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
@@ -883,6 +1389,30 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@@ -899,6 +1429,34 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -924,6 +1482,14 @@
"node": ">= 0.8"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -932,6 +1498,23 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/validator": {
"version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -940,6 +1523,19 @@
"node": ">= 0.8"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",

View File

@@ -20,8 +20,13 @@
"homepage": "https://github.com/antebrl/iptv-restream#readme",
"dependencies": {
"child_process": "^1.0.2",
"crypto": "^1.0.1",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"iptv-playlist-parser": "^0.13.0",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"request": "^2.88.2",
"socket.io": "^4.8.1"
}
}

View File

@@ -4,34 +4,85 @@ const { Server } = require('socket.io');
const ChatSocketHandler = require('./socket/ChatSocketHandler');
const ChannelSocketHandler = require('./socket/ChannelSocketHandler');
const PlaylistSocketHandler = require('./socket/PlaylistSocketHandler');
const socketAuthMiddleware = require('./socket/middleware/jwt');
const proxyController = require('./controllers/ProxyController');
const centralChannelController = require('./controllers/CentralChannelController');
const channelController = require('./controllers/ChannelController');
const streamController = require('./services/streaming/StreamController');
const authController = require('./controllers/AuthController');
const streamController = require('./services/restream/StreamController');
const ChannelService = require('./services/ChannelService');
const PlaylistUpdater = require('./services/PlaylistUpdater');
dotenv.config();
const app = express();
app.use(express.json());
const apiRouter = express.Router();
// CORS middleware
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// Auth routes
const authRouter = express.Router();
authRouter.post('/admin-login', authController.adminLogin);
authRouter.get('/admin-status', authController.checkAdminStatus);
app.use('/api/auth', authRouter);
// Channel routes
const apiRouter = express.Router();
apiRouter.get('/', channelController.getChannels);
apiRouter.get('/current', channelController.getCurrentChannel);
apiRouter.delete('/clear', authController.verifyToken, channelController.clearChannels);
apiRouter.get('/playlist', centralChannelController.playlist);
apiRouter.get('/:channelId', channelController.getChannel);
// Protected routes
apiRouter.delete('/:channelId', authController.verifyToken, channelController.deleteChannel);
apiRouter.put('/:channelId', authController.verifyToken, channelController.updateChannel);
apiRouter.post('/', authController.verifyToken, channelController.addChannel);
app.use('/api/channels', apiRouter);
const proxyRouter = express.Router();
proxyRouter.get('/channel', proxyController.channel);
proxyRouter.get('/segment', proxyController.segment);
proxyRouter.get('/key', proxyController.key);
proxyRouter.get('/current', centralChannelController.currentChannel);
app.use('/proxy', proxyRouter);
const PORT = 5000;
const server = app.listen(PORT, () => {
const server = app.listen(PORT, async () => {
console.log(`Server listening on Port ${PORT}`);
if (ChannelService.getCurrentChannel().restream) {
streamController.start(process.env.DEFAULT_CHANNEL_URL);
const currentChannel = ChannelService.getCurrentChannel();
if (currentChannel && currentChannel.restream()) {
await streamController.start(currentChannel);
}
PlaylistUpdater.startScheduler();
PlaylistUpdater.registerChannelsPlaylist(ChannelService.getChannels());
});
// Web Sockets
const io = new Server(server);
// Web Sockets with explicit CORS configuration
const io = new Server(server, {
cors: {
origin: "*", // Allow any origin in development
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Authorization", "Content-Type"],
credentials: true,
},
});
// Add JWT authentication middleware to socket.io
io.use(socketAuthMiddleware);
const connectedUsers = {};
@@ -49,6 +100,6 @@ io.on('connection', socket => {
})
ChannelSocketHandler(io, socket);
PlaylistSocketHandler(io, socket);
ChatSocketHandler(io, socket);
})

View File

@@ -1,9 +1,18 @@
const streamController = require('./streaming/StreamController');
const streamController = require('./restream/StreamController');
const Channel = require('../models/Channel');
const storageService = require('./restream/StorageService');
const ChannelStorage = require('./ChannelStorage');
class ChannelService {
constructor() {
this.channels = [new Channel('DEFAULT_CHANNEL', process.env.DEFAULT_CHANNEL_URL, "https://upload.wikimedia.org/wikipedia/commons/thumb/5/56/Das_Erste-Logo_klein.svg/768px-Das_Erste-Logo_klein.svg.png", false)];
this.channels = ChannelStorage.load();
this.currentChannel = this.channels[0];
}
clearChannels() {
ChannelStorage.clear();
this.channels = ChannelStorage.load();
this.currentChannel = this.channels[0];
}
@@ -11,28 +20,54 @@ class ChannelService {
return this.channels;
}
addChannel(name, url, avatar, restream) {
const existing = this.channels.some(channel => channel.url === url);
if (existing) {
throw new Error('Channel already exists');
getChannelById(id) {
return this.channels.find(channel => channel.id === id);
}
const newChannel = new Channel(name, url, avatar, restream);
getFilteredChannels({ playlistName, group }) {
let filtered = this.channels;
if (playlistName) {
filtered = filtered.filter(ch => ch.playlistName && ch.playlistName.toLowerCase() == playlistName.toLowerCase());
}
if (group) {
filtered = filtered.filter(ch => ch.group && ch.group.toLowerCase() === group.toLowerCase());
}
return filtered;
}
addChannel({ name, url, avatar, mode, headersJson, group = null, playlist = null, playlistName = null, playlistUpdate = false }, save = true) {
// const existing = this.channels.find(channel => channel.url === url);
// if (existing) {
// throw new Error('Channel already exists');
// }
let headers = headersJson;
try {
//Try to parse headers if not already parsed
headers = JSON.parse(headersJson);
} catch (error) {
}
const newChannel = new Channel(name, url, avatar, mode, headers, group, playlist, playlistName, playlistUpdate);
this.channels.push(newChannel);
if(save) ChannelStorage.save(this.channels);
return newChannel;
}
setCurrentChannel(id) {
async setCurrentChannel(id) {
const nextChannel = this.channels.find(channel => channel.id === id);
if (!nextChannel) {
throw new Error('Channel does not exist');
}
if (this.currentChannel !== nextChannel) {
if(nextChannel.restream) {
streamController.start(nextChannel.url);
if (nextChannel.restream()) {
streamController.stop(this.currentChannel);
storageService.deleteChannelStorage(nextChannel.id);
await streamController.start(nextChannel);
} else {
streamController.stop();
streamController.stop(this.currentChannel);
}
this.currentChannel = nextChannel;
}
@@ -42,6 +77,62 @@ class ChannelService {
getCurrentChannel() {
return this.currentChannel;
}
getChannelById(id) {
return this.channels.find(channel => channel.id === id);
}
async deleteChannel(id, save = true) {
const channelIndex = this.channels.findIndex(channel => channel.id === id);
if (channelIndex === -1) {
throw new Error('Channel does not exist');
}
// Prevent deleting the last channel
if (this.channels.length === 1) {
throw new Error('Cannot delete the last channel');
}
const [deletedChannel] = this.channels.splice(channelIndex, 1);
// If we deleted the current channel, switch to another one
if (this.currentChannel.id === id) {
const nextChannel = this.channels[0];
await this.setCurrentChannel(nextChannel.id);
}
if(save) ChannelStorage.save(this.channels);
return this.currentChannel;
}
async updateChannel(id, updatedAttributes, save = true) {
const channelIndex = this.channels.findIndex(channel => channel.id === id);
if (channelIndex === -1) {
throw new Error('Channel does not exist');
}
const streamChanged = updatedAttributes.url != this.currentChannel.url ||
JSON.stringify(updatedAttributes.headers) != JSON.stringify(this.currentChannel.headers) ||
updatedAttributes.mode != this.currentChannel.mode;
const channel = this.channels[channelIndex];
Object.assign(channel, updatedAttributes);
if (this.currentChannel.id == id) {
if (streamChanged) {
streamController.stop(channel);
if (channel.restream()) {
await streamController.start(channel);
}
}
}
if(save) ChannelStorage.save(this.channels);
return channel;
}
}
module.exports = new ChannelService();

View File

@@ -0,0 +1,65 @@
const fs = require("fs");
const path = require("path");
const Channel = require("../models/Channel");
const { clear } = require("console");
const storageFilePath = path.resolve("/channels/channels.json");
module.exports = {
load() {
const defaultChannels = [
//Some Test-channels to get started
new Channel(
"BBC",
"https://bcovlive-a.akamaihd.net/7f5ec16d102f4b5d92e8e27bc95ff424/us-east-1/6240731308001/playlist.m3u8",
"https://upload.wikimedia.org/wikipedia/commons/4/41/BBC_Logo_2021.svg",
"proxy",
[]
),
new Channel(
"BeIn Sports",
"http://fl2.moveonjoy.com/BEIN_SPORTS/index.m3u8",
"https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/bein-sports-us.png?raw=true",
"proxy",
[]
),
];
if (fs.existsSync(storageFilePath)) {
try {
const data = fs.readFileSync(storageFilePath, "utf-8");
const channelsJson = JSON.parse(data);
return channelsJson.map((channelJson) => Channel.from(channelJson));
} catch (err) {
console.error("Error loading data from storage:", err);
return defaultChannels;
}
}
this.save(defaultChannels);
return defaultChannels;
},
save(data) {
try {
fs.writeFile(
storageFilePath,
JSON.stringify(data, null, 2),
{ encoding: "utf-8" },
(err) => err && console.error(err)
);
console.log("Data saved successfully.");
} catch (err) {
console.error("Error saving data to storage:", err);
}
},
clear() {
try {
fs.unlinkSync(storageFilePath);
console.log("Data cleared successfully.");
} catch (err) {
console.error("Error clearing data from storage:", err);
}
},
};

View File

@@ -0,0 +1,97 @@
const m3uParser = require('iptv-playlist-parser');
const ChannelService = require('./ChannelService');
const ChannelStorage = require('./ChannelStorage');
const StreamedSuSession = require('./session/StreamedSuSession');
class PlaylistService {
async addPlaylist(playlist, playlistName, mode, playlistUpdate, headersJson) {
console.log('Adding playlist', playlist);
let content = "";
if(playlist.startsWith("http")) {
const response = await fetch(playlist);
content = await response.text();
//check for streamedSu here and add channel for every source
if(playlist.includes('streamed.su')) {
console.log('Fetching StreamedSu API channels');
const channels = await StreamedSuSession.fetchApiChannels(playlist, mode, playlistName);
const data = channels.map(channel => {
return ChannelService.addChannel(channel, false);
});
ChannelStorage.save(ChannelService.getChannels());
return data;
}
} else {
content = playlist;
const fs = require('fs');
fs.writeFileSync(`/channels/${playlistName}.txt`, playlist, { encoding: 'utf-8' }, (err) => err && console.error(err));
console.log('Adding playlist to playlist.m3u8');
}
const parsedPlaylist = m3uParser.parse(content);
// list of added channels
const channels = parsedPlaylist.items.map(channel => {
//TODO: add channel.http if not '' to headers
try {
return ChannelService.addChannel({
name: channel.name,
url: channel.url,
avatar: channel.tvg.logo,
mode: mode,
headersJson: headersJson,
group: channel.group.title,
playlist: playlist,
playlistName: playlistName,
playlistUpdate: playlistUpdate
}, false);
} catch (error) {
console.error(error);
return null;
}
})
.filter(result => result !== null);
ChannelStorage.save(ChannelService.getChannels());
return channels;
}
async updatePlaylist(playlistUrl, updatedAttributes) {
// Update channels attributes
const channels = ChannelService
.getChannels()
.filter(channel => channel.playlist === playlistUrl);
for(let channel of channels) {
channel = await ChannelService.updateChannel(channel.id, updatedAttributes, false);
}
ChannelStorage.save(ChannelService.getChannels());
return channels;
}
async deletePlaylist(playlistUrl) {
console.log('Deleting playlist', playlistUrl);
const channels = ChannelService
.getChannels()
.filter(channel => channel.playlist === playlistUrl);
for(const channel of channels) {
await ChannelService.deleteChannel(channel.id, false);
}
ChannelStorage.save(ChannelService.getChannels());
return channels;
}
}
module.exports = new PlaylistService();

View File

@@ -0,0 +1,74 @@
const cron = require('node-cron');
const PlaylistService = require('./PlaylistService');
const Playlist = require('../models/Playlist');
class PlaylistUpdater {
constructor() {
this.playlists = new Map();
}
#contains(playlistUrl) {
return this.playlists.has(playlistUrl);
}
register(playlist) {
if (this.#contains(playlist.playlist)) {
return;
}
console.log('Registering playlist:', playlist.playlist);
this.playlists.set(playlist.playlist, playlist);
}
registerChannelsPlaylist(channels) {
for (const channel of channels) {
if (channel.playlist && channel.playlistUpdate) {
this.register(
new Playlist(
channel.playlist,
channel.playlistName,
channel.mode,
channel.playlistUpdate,
channel.headers
)
);
}
}
}
delete(playlistUrl) {
if (this.#contains(playlistUrl)) {
this.playlists.delete(playlistUrl);
console.log(`Deleted playlist with URL: ${playlistUrl}`);
}
}
startScheduler() {
// Cron-Job: "0 3 * * *" -> Every day at 3:00 AM
cron.schedule('0 3 * * *', () => {
this.updatePlaylists();
});
}
updatePlaylists() {
console.log('Updating playlists at:', new Date());
this.playlists.forEach(async (playlist) => {
try {
// Fetch and renew playlist
await PlaylistService.deletePlaylist(playlist.playlist);
console.log('Adding playlist with playlistUpdate:', playlist.playlistUpdate);
await PlaylistService.addPlaylist(
playlist.playlist,
playlist.playlistName,
playlist.mode,
playlist.playlistUpdate,
playlist.headers
);
} catch (error) {
console.error(`Error while updating playlist ${playlist.playlistName}:`, error);
}
});
}
}
module.exports = new PlaylistUpdater();

View File

@@ -0,0 +1,82 @@
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
require("dotenv").config();
/**
* Service for handling JWT authentication
*/
class AuthService {
constructor() {
this.ADMIN_ENABLED = process.env.ADMIN_ENABLED === "true";
this.CHANNEL_SELECTION_REQUIRES_ADMIN =
process.env.CHANNEL_SELECTION_REQUIRES_ADMIN === "true";
this.ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
this.JWT_EXPIRY = process.env.JWT_EXPIRY || "24h";
// Validate admin password if admin mode is enabled
if (
this.ADMIN_ENABLED &&
(!this.ADMIN_PASSWORD || this.ADMIN_PASSWORD.length < 12)
) {
throw new Error(
"ADMIN_PASSWORD must be set and at least 12 characters long for security."
);
}
// Generate a secure JWT secret from the admin password
// or use a random value if admin mode is disabled
this.JWT_SECRET = crypto
.createHash("sha256")
.update(this.ADMIN_PASSWORD || "")
.digest("hex");
}
/**
* Check if channel selection needs admin
* @returns {boolean}
*/
channelSelectionRequiresAdmin() {
return this.CHANNEL_SELECTION_REQUIRES_ADMIN && this.ADMIN_ENABLED;
}
/**
* Generate a JWT token for an admin user
* @returns {string} JWT token
*/
generateAdminToken() {
return jwt.sign({ isAdmin: true }, this.JWT_SECRET, {
expiresIn: this.JWT_EXPIRY,
});
}
/**
* Verify a JWT token
* @param {string} token - The JWT token to verify
* @returns {Object|null} Decoded token payload or null if invalid
*/
verifyToken(token) {
try {
return jwt.verify(token, this.JWT_SECRET);
} catch (error) {
return null;
}
}
/**
* Check if admin mode is enabled
* @returns {boolean} True if admin mode is enabled
*/
isAdminEnabled() {
return this.ADMIN_ENABLED;
}
/**
* Verify admin password
* @param {string} password - Password to verify
* @returns {boolean} True if password matches
*/
verifyAdminPassword(password) {
return this.ADMIN_PASSWORD === password;
}
}
module.exports = new AuthService();

View File

@@ -0,0 +1,74 @@
const url = require('url');
const path = require('path');
class ProxyHelperService {
getBaseUrl(fullUrl) {
const parsedUrl = url.parse(fullUrl);
return `${parsedUrl.protocol}//${parsedUrl.host}${path.dirname(parsedUrl.pathname)}/`;
}
getRequestOptions(targetUrl, base64Headers) {
let requestOptions = { url: targetUrl };
if (base64Headers) {
try {
const parsedHeaders = JSON.parse(Buffer.from(base64Headers, 'base64').toString('ascii'));
requestOptions.headers = parsedHeaders.reduce((acc, header) => {
acc[header.key] = header.value;
return acc;
}, {});
} catch (e) {
console.error('Failed to parse headers:', e);
}
}
return requestOptions;
}
rewriteUrls(body, proxyBaseUrl, headers, originalUrl) {
let isMaster = true;
const baseUrl = originalUrl ? this.getBaseUrl(originalUrl) : '';
if(body.indexOf('#EXT-X-STREAM-INF') !== -1) {
isMaster = false;
}
let lines = body.split('\n');
return lines.map(line => {
line = line.trim();
if (line.startsWith('#')) {
const keyURI = line.startsWith('#EXT-X-KEY');
return line.replace(
/URI="([^"]+)"/,
(_, originalKeyUrl) => {
// If the key URL is relative, make it absolute
const absoluteKeyUrl = originalKeyUrl.startsWith('http') ?
originalKeyUrl :
url.resolve(baseUrl, originalKeyUrl);
return `URI="${proxyBaseUrl}${keyURI ? 'key' : 'channel'}?url=${encodeURIComponent(absoluteKeyUrl)}${headers ? `&headers=${headers}` : ''}"`;
}
);
} else if (line.length > 0) {
if(line.indexOf('.m3u8') !== -1) {
isMaster = false;
}
// Handle segment URLs
if (line.startsWith('http')) {
return `${proxyBaseUrl}${isMaster ? 'segment' : 'channel'}?url=${encodeURIComponent(line)}${headers ? `&headers=${headers}` : ''}`;
}
else {
// Relative URL case - combine with base URL
const absoluteUrl = url.resolve(baseUrl, line);
return `${proxyBaseUrl}${isMaster ? 'segment' : 'channel'}?url=${encodeURIComponent(absoluteUrl)}${headers ? `&headers=${headers}` : ''}`;
}
}
return line; // Return empty lines unchanged
});
}
}
module.exports = new ProxyHelperService();

View File

@@ -0,0 +1,85 @@
const { spawn } = require('child_process');
require('dotenv').config();
let currentFFmpegProcess = null;
let currentChannelId = null;
const STORAGE_PATH = process.env.STORAGE_PATH;
function startFFmpeg(nextChannel) {
console.log('Starting FFmpeg process with channel:', nextChannel.id);
// if (currentFFmpegProcess) {
// console.log('Gracefully terminate previous ffmpeg-Prozess...');
// await stopFFmpeg();
// }
let channelUrl = nextChannel.sessionUrl ? nextChannel.sessionUrl : nextChannel.url;
currentChannelId = nextChannel.id;
const headers = nextChannel.headers;
currentFFmpegProcess = spawn('ffmpeg', [
'-headers', headers.map(header => `${header.key}: ${header.value}`).join('\r\n'),
'-reconnect', '1',
'-reconnect_at_eof', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '2',
'-i', channelUrl,
'-c', 'copy',
'-f', 'hls',
'-hls_time', '6',
'-hls_list_size', '5',
'-hls_flags', 'delete_segments+program_date_time',
'-start_number', Math.floor(Date.now() / 1000),
`${STORAGE_PATH}${currentChannelId}/${currentChannelId}.m3u8`
]);
currentFFmpegProcess.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
currentFFmpegProcess.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
// currentFFmpegProcess.on('close', (code) => {
// console.log(`ffmpeg-Process terminated with code: ${code}`);
// // currentFFmpegProcess = null;
// // //Restart if crashed
// // if (code !== null && code !== 255) {
// // console.log(`Restarting FFmpeg process with channel: ${nextChannel.id}`);
// // //wait 1 second before restarting
// // setTimeout(() => startFFmpeg(nextChannel), 2000);
// // }
// });
}
function stopFFmpeg() {
return new Promise((resolve, reject) => {
if (currentFFmpegProcess) {
console.log('Gracefully terminate ffmpeg-Process...');
currentFFmpegProcess.on('close', (code) => {
console.log(`ffmpeg-Process terminated with code: ${code}`);
currentFFmpegProcess = null;
resolve();
});
currentFFmpegProcess.kill('SIGTERM');
} else {
console.log('No ffmpeg process is running.');
resolve();
}
});
}
function isFFmpegRunning() {
return currentFFmpegProcess !== null;
}
module.exports = {
startFFmpeg,
stopFFmpeg,
isFFmpegRunning
};

View File

@@ -0,0 +1,17 @@
const fs = require('fs');
const STORAGE_PATH = process.env.STORAGE_PATH;
function createChannelStorage(channelId) {
fs.mkdirSync(STORAGE_PATH + channelId);
}
function deleteChannelStorage(channelId) {
fs.rmSync(STORAGE_PATH + channelId, { recursive: true, force: true });
}
module.exports = {
deleteChannelStorage,
createChannelStorage
};

View File

@@ -0,0 +1,32 @@
const ffmpegService = require('./FFmpegService');
const storageService = require('./StorageService');
const SessionFactory = require('../session/SessionFactory');
async function start(nextChannel) {
console.log('Starting channel', nextChannel.id);
storageService.createChannelStorage(nextChannel.id);
const sessionProvider = SessionFactory.getSessionProvider(nextChannel);
if(sessionProvider) {
await sessionProvider.createSession();
}
ffmpegService.startFFmpeg(nextChannel);
}
async function stop(channel) {
console.log('Stopping channel', channel.id);
if (ffmpegService.isFFmpegRunning()) {
await ffmpegService.stopFFmpeg();
}
channel.sessionUrl = null;
storageService.deleteChannelStorage(channel.id);
}
module.exports = {
start,
stop
};

View File

@@ -0,0 +1,14 @@
const StreamedSuSession = require('./StreamedSuSession');
class SessionFactory {
static getSessionProvider(channel) {
switch (true) {
case channel.url.includes('vipstreams.in'): //StreamedSU
return new StreamedSuSession(channel);
default:
return null;
}
}
}
module.exports = SessionFactory;

View File

@@ -0,0 +1,22 @@
//Implement this interface for your specific session provider
class SessionHandler {
constructor() {
if (this.constructor === SessionHandler) {
throw new Error("Abstract class cannot be instantiated");
}
}
async createSession() {
throw new Error("Method 'startSession()' must be implemented");
}
// destroySession() {
// throw new Error("Method 'destroySession()' must be implemented");
// }
// getSessionQuery() {
// throw new Error("Method 'getSessionQuery()' must be implemented");
// }
}
module.exports = SessionHandler;

View File

@@ -0,0 +1,100 @@
const SessionHandler = require('./SessionHandler');
class StreamedSuSession extends SessionHandler {
constructor(channel) {
super();
this.channel = channel;
}
static streamedSuHeaders = [
{ "key": "Origin", "value": "https://embedme.top" },
{ "key": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0" },
{ "key": "Referer", "value": "https://embedme.top/" }
];
static async fetchApiChannels(apiUrl, mode, playlistName) {
try {
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(response);
}
const data = await response.json();
let channels = [];
data.forEach(stream => {
const { title, category, poster, sources } = stream;
for(const source of sources) {
const channelUrl = `https://rr.vipstreams.in/${source.source}/js/${source.id}/1/playlist.m3u8`;
const channelAvatar = `https://streamed.su${poster ?? '/api/images/poster/fallback.webp'}`;
channels.push({
name: title,
url: channelUrl,
avatar: channelAvatar,
mode: mode,
headersJson: this.streamedSuHeaders,
group: category,
playlist: apiUrl,
playlistName: playlistName
});
}
});
return channels;
} catch (error) {
console.error('Fetch StreamedSu API failed:', error);
return [];
}
}
async createSession() {
console.log('Creating session:', this.channel.url);
try {
const parts = this.channel.url.split("/");
const bodyData = { source: parts[3], id: parts[5], streamNo: parts[6] };
const headers = this.channel.headers.reduce((acc, header) => {
acc[header.key] = header.value;
return acc;
}, {});
const response = await fetch('https://embedme.top/fetch', {
method: "POST",
headers: headers,
body: JSON.stringify(bodyData)
});
if (!response.ok) {
console.log('Failed to initialize session: ', response);
throw new Error('Failed to initialize session');
}
const encryptedData = await response.text();
const decryptUrl = `https://streamed-su-decrypt-api.vercel.app/api/decrypt?data=${encodeURIComponent(encryptedData)}`;
const decryptRes = await fetch(decryptUrl, { method: "GET" });
if (!decryptRes.ok) {
console.log('Failed to decrypt session: ', response);
throw new Error('Failed to decrypt session');
}
const sessionDecrypted = await decryptRes.json();
this.channel.sessionUrl = "https://rr.vipstreams.in" + sessionDecrypted.ok;
console.log('Session URL:', this.channel.sessionUrl);
return sessionDecrypted.ok;
} catch (error) {
console.error('Session initialization failed:', error);
this.channel.sessionUrl = null;
}
}
}
module.exports = StreamedSuSession;

View File

@@ -1,54 +0,0 @@
const { spawn } = require('child_process');
require('dotenv').config();
let currentFFmpegProcess = null;
const STORAGE_PATH = process.env.STORAGE_PATH;
function startFFmpeg(channelUrl) {
if (currentFFmpegProcess) {
console.log('Terminate previous ffmpeg-Prozess...');
currentFFmpegProcess.kill('SIGTERM');
}
currentFFmpegProcess = spawn('ffmpeg', [
'-i', channelUrl,
'-c', 'copy',
'-f', 'hls',
'-hls_time', '6',
'-hls_list_size', '5',
'-hls_flags', 'delete_segments+program_date_time',
'-start_number', Math.floor(Date.now() / 1000),
`${STORAGE_PATH}/playlist.m3u8`
]);
currentFFmpegProcess.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
currentFFmpegProcess.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
currentFFmpegProcess.on('close', (code) => {
console.log(`ffmpeg-Process terminated with code: ${code}`);
});
}
function stopFFmpeg() {
if (currentFFmpegProcess) {
console.log('Terminate ffmpeg-Process...');
currentFFmpegProcess.kill('SIGTERM');
currentFFmpegProcess = null;
}
}
function isFFmpegRunning() {
return currentFFmpegProcess !== null;
}
module.exports = {
startFFmpeg,
stopFFmpeg,
isFFmpegRunning
};

View File

@@ -1,21 +0,0 @@
const fs = require('fs');
const path = require("path");
const STORAGE_PATH = process.env.STORAGE_PATH;
function clearStorage() {
fs.readdir(STORAGE_PATH, (err, files) => {
if (err) throw err;
for (const file of files) {
fs.unlink(path.join(STORAGE_PATH, file), (err) => {
if (err) throw err;
});
}
});
}
module.exports = {
clearStorage
};

View File

@@ -1,22 +0,0 @@
const ffmpegService = require('./FFmpegService');
const storageService = require('./StorageService');
function start(channelUrl) {
stop();
if (!ffmpegService.isFFmpegRunning()) {
ffmpegService.startFFmpeg(channelUrl);
}
}
function stop() {
if (ffmpegService.isFFmpegRunning()) {
ffmpegService.stopFFmpeg();
}
storageService.clearStorage();
}
module.exports = {
start,
stop
};

View File

@@ -1,24 +1,86 @@
const ChannelService = require('../services/ChannelService');
const ChannelService = require("../services/ChannelService");
const authService = require("../services/auth/AuthService");
module.exports = (io, socket) => {
socket.on('add-channel', ({ name, url, avatar, restream }) => {
// Check if admin mode is required for channel modifications
socket.on("add-channel", ({ name, url, avatar, mode, headersJson }) => {
try {
const newChannel = ChannelService.addChannel(name, url, avatar, restream);
io.emit('channel-added', newChannel); // Broadcast to all clients
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to add channels",
});
}
console.log("Adding solo channel:", url);
const newChannel = ChannelService.addChannel({
name: name,
url: url,
avatar: avatar,
mode: mode,
headersJson: headersJson,
});
io.emit("channel-added", newChannel); // Broadcast to all clients
} catch (err) {
socket.emit('app-error', { message: err.message });
socket.emit("app-error", { message: err.message });
}
});
socket.on('set-current-channel', (id) => {
socket.on("set-current-channel", async (id) => {
try {
const nextChannel = ChannelService.setCurrentChannel(id);
io.emit('channel-selected', nextChannel); // Broadcast to all clients
if (
authService.isAdminEnabled() &&
authService.channelSelectionRequiresAdmin() &&
!socket.user?.isAdmin
) {
return socket.emit("app-error", {
message: "Admin access required to switch channel",
});
}
const nextChannel = await ChannelService.setCurrentChannel(id);
io.emit("channel-selected", nextChannel); // Broadcast to all clients
} catch (err) {
console.error(err);
socket.emit('app-error', { message: err.message });
socket.emit("app-error", { message: err.message });
}
});
socket.on("delete-channel", async (id) => {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to delete channels",
});
}
const lastChannel = ChannelService.getCurrentChannel();
const current = await ChannelService.deleteChannel(id);
io.emit("channel-deleted", id); // Broadcast to all clients
if (lastChannel.id != current.id) io.emit("channel-selected", current);
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
});
socket.on("update-channel", async ({ id, updatedAttributes }) => {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to update channels",
});
}
const updatedChannel = await ChannelService.updateChannel(
id,
updatedAttributes
);
io.emit("channel-updated", updatedChannel); // Broadcast to all clients
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
});
};

View File

@@ -0,0 +1,124 @@
const PlaylistService = require("../services/PlaylistService");
const ChannelService = require("../services/ChannelService");
const PlaylistUpdater = require("../services/PlaylistUpdater");
const Playlist = require("../models/Playlist");
const authService = require("../services/auth/AuthService");
require("dotenv").config();
async function handleAddPlaylist(
{ playlist, playlistName, mode, playlistUpdate, headers },
io,
socket
) {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to add playlists",
});
}
const channels = await PlaylistService.addPlaylist(
playlist,
playlistName,
mode,
playlistUpdate,
headers
);
if (channels) {
channels.forEach((channel) => {
io.emit("channel-added", channel);
});
}
if (playlistUpdate) {
PlaylistUpdater.register(
new Playlist(playlist, playlistName, mode, playlistUpdate, headers)
);
}
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
}
async function handleUpdatePlaylist(
{ playlist, updatedAttributes },
io,
socket
) {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to update playlists",
});
}
if (playlist !== updatedAttributes.playlist) {
// Playlist URL has changed - delete channels and fetch again
await handleDeletePlaylist({ playlist }, io, socket);
await handleAddPlaylist({ ...updatedAttributes }, io, socket);
return;
}
const channels = await PlaylistService.updatePlaylist(
playlist,
updatedAttributes
);
channels.forEach((channel) => {
io.emit("channel-updated", channel);
});
PlaylistUpdater.delete(playlist);
if (updatedAttributes.playlistUpdate) {
PlaylistUpdater.register(
new Playlist(
playlist,
updatedAttributes.playlistName,
updatedAttributes.mode,
updatedAttributes.playlistUpdate,
updatedAttributes.headers
)
);
}
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
}
async function handleDeletePlaylist({ playlist }, io, socket) {
try {
// Check if user is authenticated as admin from the socket middleware
if (authService.isAdminEnabled() && !socket.user?.isAdmin) {
return socket.emit("app-error", {
message: "Admin access required to delete playlists",
});
}
const channels = await PlaylistService.deletePlaylist(playlist);
channels.forEach((channel) => {
io.emit("channel-deleted", channel.id);
});
io.emit("channel-selected", ChannelService.getCurrentChannel());
PlaylistUpdater.delete(playlist);
} catch (err) {
console.error(err);
socket.emit("app-error", { message: err.message });
}
}
module.exports = (io, socket) => {
socket.on("add-playlist", (data) => handleAddPlaylist(data, io, socket));
socket.on("update-playlist", (data) =>
handleUpdatePlaylist(data, io, socket)
);
socket.on("delete-playlist", (playlist) =>
handleDeletePlaylist({ playlist }, io, socket)
);
};

View File

@@ -0,0 +1,23 @@
const authService = require("../../services/auth/AuthService");
/**
* Socket.io middleware to authenticate users via JWT token
*/
function socketAuthMiddleware(socket, next) {
// Retrieve token from handshake auth or query param
const token = socket.handshake.auth.token || socket.handshake.query.token;
if (!token) {
// Allow connection but without admin privileges
socket.user = { isAdmin: false };
return next();
}
const decoded = authService.verifyToken(token);
// Attach the decoded user info (or default non-admin) to the socket
socket.user = decoded || { isAdmin: false };
return next();
}
module.exports = socketAuthMiddleware;

39
deployment/README.md Normal file
View File

@@ -0,0 +1,39 @@
# Advanced Deployment
## Configuration Options
### Admin Mode
Admin Mode restricts channel management to authenticated administrators only.
**Configuration (in `docker-compose.yml` > iptv_restream_backend):**
- `ADMIN_ENABLED`: Enable admin mode (`true` or `false` [default]).
- `ADMIN_PASSWORD`: Set a secure password for admin login (required if admin mode is enabled).
- `CHANNEL_SELECTION_REQUIRES_ADMIN`: If set to `true`, only admins can switch the currently watched channel.
## Docker
### Easy Way (Preffered)
Clone the repo
```bash
git clone https://github.com/antebrl/IPTV-Restream.git
```
Make sure to have docker up & running. Start with docker compose
```bash
docker compose up -d
```
Open http://localhost
### Production Build (with SSL certificate)
If you want to expose the application to the public under your domain, [this](docker-compose.yml) could be an easy deployment to get it working with `https`. You still have to configure nginx-proxy-manager when using this solution.
### Prebuild Images
Use the [ghcr-docker-compose.yml](ghcr-docker-compose.yml).
```bash
docker compose -f ghcr-docker-compose.yml up
```
Disadvantages:
- not always up to date
- cannot set custom configuration for the frontend, as the config is parsed in the image build process

View File

@@ -0,0 +1,70 @@
version: "3.9"
services:
iptv_restream_frontend:
build:
context: ../frontend
dockerfile: Dockerfile
args:
# Set this to the server ip/domain, if your backend is deployed on a different server
#VITE_BACKEND_URL: http://123.123.123.123:5000
VITE_STREAM_DELAY: 18
VITE_STREAM_PROXY_DELAY: 30
# Optional settings for synchronization
#VITE_SYNCHRONIZATION_TOLERANCE: 0.8
#VITE_SYNCHRONIZATION_MAX_DEVIATION: 4
#VITE_SYNCHRONIZATION_ADJUSTMENT: 0.06
#VITE_SYNCHRONIZATION_MAX_ADJUSTMENT: 0.16
networks:
- app-network
iptv_restream_backend:
build:
context: ../backend
dockerfile: Dockerfile
restart: unless-stopped
volumes:
- streams_data:/streams
- channels:/channels
environment:
STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000
networks:
- app-network
# Nginx Reverse Proxy
iptv_restream_nginx:
image: nginx:alpine
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- streams_data:/streams
networks:
- app-network
nginx-proxy-manager:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '81:81'
# Nginx Proxy Manager Web - '81:81'
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- app-network
# Volumes
volumes:
streams_data:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
channels:
# Internal docker network
networks:
app-network:
driver: bridge

View File

@@ -0,0 +1,42 @@
version: "3.9"
services:
iptv_restream_frontend:
image: ghcr.io/antebrl/iptv-restream/frontend:v2.2
networks:
- app-network
iptv_restream_backend:
image: ghcr.io/antebrl/iptv-restream/backend:v2.2
volumes:
- streams_data:/streams
- channels:/channels
restart: unless-stopped
environment:
STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000
networks:
- app-network
# Nginx Reverse Proxy
iptv_restream_nginx:
image: ghcr.io/antebrl/iptv-restream/nginx:v2.2
volumes:
- streams_data:/streams
ports:
- "80:80" # Configure exposed port, if 80 is already in use e.g. 8080:80
networks:
- app-network
volumes:
streams_data:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
channels:
# Internal docker network
networks:
app-network:
driver: bridge

View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf

View File

@@ -0,0 +1,78 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
#Forward header if there is a proxy in front otherwise set the headers
map $http_x_forwarded_proto $x_forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
map $http_x_forwarded_port $x_forwarded_port {
default $http_x_forwarded_port;
"" $server_port;
}
server {
listen 80;
location / {
proxy_pass http://iptv_restream_frontend:80;
}
location /api/ {
proxy_pass http://iptv_restream_backend:5000;
proxy_set_header X-Forwarded-Proto $x_forwarded_proto;
proxy_set_header X-Forwarded-Port $x_forwarded_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header 'Access-Control-Allow-Origin' '*';
}
location /socket.io/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://iptv_restream_backend:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /proxy/ {
proxy_pass http://iptv_restream_backend:5000;
proxy_set_header Host $host;
# add_header 'Access-Control-Allow-Origin' '*';
# add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
proxy_hide_header Content-Type;
add_header Content-Type 'application/vnd.apple.mpegurl' always;
add_header Cache-Control no-cache;
add_header Pragma no-cache;
}
location /streams/ {
root /;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range';
add_header Cache-Control no-cache;
add_header Pragma no-cache;
}
}
}

View File

@@ -1,34 +1,48 @@
version: "3.9"
services:
frontend:
iptv_restream_frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
# Set this to the server ip/domain, if your backend is deployed on a different server
#VITE_BACKEND_URL: http://123.123.123.123:5000
VITE_BACKEND_STREAMS_PATH: /streams/playlist.m3u8
VITE_STREAM_DELAY: 18
VITE_STREAM_PROXY_DELAY: 30
# Optional settings for synchronization
#VITE_SYNCHRONIZATION_TOLERANCE: 0.8
#VITE_SYNCHRONIZATION_MAX_DEVIATION: 4
#VITE_SYNCHRONIZATION_ADJUSTMENT: 0.06
#VITE_SYNCHRONIZATION_MAX_ADJUSTMENT: 0.16
networks:
- app-network
backend:
iptv_restream_backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
volumes:
- streams_data:/streams
- channels:/channels
environment:
DEFAULT_CHANNEL_URL: https://mcdn.daserste.de/daserste/de/master.m3u8
STORAGE_PATH: /streams
STORAGE_PATH: /streams/
# If you have problems with the playlist, set the backend url manually here
#BACKEND_URL: http://localhost:5000
# Admin mode configuration
# ADMIN_ENABLED: "true"
# ADMIN_PASSWORD: "your_secure_password"
# CHANNEL_SELECTION_REQUIRES_ADMIN: "true"
networks:
- app-network
# Nginx Reverse Proxy
nginx:
iptv_restream_nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./deployment/nginx/nginx.conf:/etc/nginx/nginx.conf
- streams_data:/streams
ports:
- "80:80"
@@ -37,6 +51,11 @@ services:
volumes:
streams_data:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
channels:
# Internal docker network
networks:

View File

@@ -4,8 +4,12 @@ FROM node:20 AS builder
WORKDIR /app
ARG VITE_BACKEND_URL
ARG VITE_BACKEND_STREAMS_PATH
ARG VITE_STREAM_DELAY
ARG VITE_STREAM_PROXY_DELAY
ARG VITE_SYNCHRONIZATION_TOLERANCE
ARG VITE_SYNCHRONIZATION_MAX_DEVIATION
ARG VITE_SYNCHRONIZATION_ADJUSTMENT
ARG VITE_SYNCHRONIZATION_MAX_ADJUSTMENT
# Install dependencies
COPY package.json package-lock.json ./
@@ -15,8 +19,12 @@ RUN npm install
COPY . .
ENV VITE_BACKEND_URL=$VITE_BACKEND_URL
ENV VITE_BACKEND_STREAMS_PATH=$VITE_BACKEND_STREAMS_PATH
ENV VITE_STREAM_DELAY=$VITE_STREAM_DELAY
ENV VITE_STREAM_PROXY_DELAY=$VITE_STREAM_PROXY_DELAY
ENV VITE_SYNCHRONIZATION_TOLERANCE=$VITE_SYNCHRONIZATION_TOLERANCE
ENV VITE_SYNCHRONIZATION_MAX_DEVIATION=$VITE_SYNCHRONIZATION_MAX_DEVIATION
ENV VITE_SYNCHRONIZATION_ADJUSTMENT=$VITE_SYNCHRONIZATION_ADJUSTMENT
ENV VITE_SYNCHRONIZATION_MAX_ADJUSTMENT=$VITE_SYNCHRONIZATION_MAX_ADJUSTMENT
RUN npm run build

View File

@@ -1,11 +1,41 @@
# Frontend
A simple React webpage that can stream iptv streams in hls-format. Provides synchronized playback by using a constant delay. Also supports multiple IPTV streams (channel selection) and a chat if using together with the backend.
## 🚀 Run
It is strongly advised to [use the frontend together with the backend](../deployment/README.md). There is also a `direct` mode which doesn't route the iptv traffic through the backend.
If you still want to use it standalone, consider these options:
### With Docker (Preferred)
```bash
docker run -d \
--name iptv_restream_frontend \
-p 8080:80 \
ghcr.io/antebrl/iptv-restream/frontend:{currentVersion}
```
See the `{currentVersion}` in the [package registry](https://github.com/antebrl/IPTV-Restream/pkgs/container/iptv-restream%2Ffrontend) e.g. `v2.1`.
#### Build
In this directory:
```bash
docker build --build-arg VITE_BACKEND_STREAMS_PATH=/streams/ --build-arg VITE_STREAM_DELAY=18 -t iptv_restream_frontend
```
```bash
docker run -d iptv_restream_backend
```
### Bare metal
Setup a `.env` file or
equivalent environment variables:
```env
#VITE_BACKEND_URL: http://123.123.123.123:5000
VITE_BACKEND_STREAMS_PATH: /streams/playlist.m3u8
VITE_STREAM_DELAY: 18
```
@@ -16,3 +46,7 @@ npm run dev
```
To use together with the backend, [run with docker](../README.md#run-with-docker-preferred).
## Usage without the backend
You have to make some adjustments in the code as the frontend requires websocket connection to the backend at the moment. Use this at your own risk.

View File

@@ -8,7 +8,8 @@
"name": "vite-react-typescript-starter",
"version": "0.0.0",
"dependencies": {
"hls.js": "^1.4.0",
"hls.js": "^1.6.0-beta.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -2547,9 +2548,9 @@
}
},
"node_modules/hls.js": {
"version": "1.5.17",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz",
"integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw=="
"version": "1.6.0-beta.2",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.0-beta.2.tgz",
"integrity": "sha512-m0MyvPNZj5ZOoOXPwHAALyPqDlJrGTOnHnZCG5FkID6q1ZneYokS/LwQjjQpYceCfxQiUT1zofT1o7Sq9FLGvA=="
},
"node_modules/ignore": {
"version": "5.3.2",
@@ -2749,6 +2750,15 @@
"node": ">=6"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5791,9 +5801,9 @@
}
},
"hls.js": {
"version": "1.5.17",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz",
"integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw=="
"version": "1.6.0-beta.2",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.0-beta.2.tgz",
"integrity": "sha512-m0MyvPNZj5ZOoOXPwHAALyPqDlJrGTOnHnZCG5FkID6q1ZneYokS/LwQjjQpYceCfxQiUT1zofT1o7Sq9FLGvA=="
},
"ignore": {
"version": "5.3.2",
@@ -5934,6 +5944,11 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true
},
"jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="
},
"keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",

View File

@@ -10,7 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"hls.js": "^1.4.0",
"hls.js": "^1.6.0-beta.2",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -1,22 +1,84 @@
import { useState, useEffect } from 'react';
import { Search, Plus, Settings, Users, Radio, Tv2 } from 'lucide-react';
import { useState, useEffect, useMemo, useContext } from 'react';
import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown, Shield } from 'lucide-react';
import VideoPlayer from './components/VideoPlayer';
import ChannelList from './components/ChannelList';
import Chat from './components/chat/Chat';
import AddChannelModal from './components/AddChannelModal';
import ChannelModal from './components/add_channel/ChannelModal';
import { Channel } from './types';
import socketService from './services/SocketService';
import apiService from './services/ApiService';
import SettingsModal from './components/SettingsModal';
import TvPlaylistModal from './components/TvPlaylistModal';
import { ToastProvider, ToastContext } from './components/notifications/ToastContext';
import ToastContainer from './components/notifications/ToastContainer';
import { AdminProvider, useAdmin } from './components/admin/AdminContext';
import AdminModal from './components/admin/AdminModal';
function AppContent() {
function App() {
const [channels, setChannels] = useState<Channel[]>([]);
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isTvPlaylistOpen, setIsTvPlaylistOpen] = useState(false);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
const [syncEnabled, setSyncEnabled] = useState(() => {
const savedValue = localStorage.getItem('syncEnabled');
return savedValue !== null ? JSON.parse(savedValue) : false;
});
const [searchQuery, setSearchQuery] = useState('');
const [editChannel, setEditChannel] = useState<Channel | null>(null);
const [selectedPlaylist, setSelectedPlaylist] = useState<string>('All Channels');
const [selectedGroup, setSelectedGroup] = useState<string>('Category');
const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false);
const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false);
const { isAdmin, isAdminEnabled, setIsAdminEnabled, channelSelectRequiresAdmin, setChannelSelectRequiresAdmin } = useAdmin();
const { addToast } = useContext(ToastContext);
// Get unique playlists from channels
const playlists = useMemo(() => {
const uniquePlaylists = new Set(channels.map(channel => channel.playlistName).filter(playlistName => playlistName !== null));
return ['All Channels', ...Array.from(uniquePlaylists)];
}, [channels]);
const filteredChannels = useMemo(() => {
//Filter by playlist
let filteredByPlaylist = selectedPlaylist === 'All Channels' ? channels : channels.filter(channel =>
channel.playlistName === selectedPlaylist
);
//Filter by group
filteredByPlaylist = selectedGroup === 'Category' ? filteredByPlaylist : filteredByPlaylist.filter(channel =>
channel.group === selectedGroup
);
//Filter by name search
return filteredByPlaylist.filter(channel =>
channel.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [channels, selectedPlaylist, selectedGroup, searchQuery]);
const groups = useMemo(() => {
let uniqueGroups;
if (selectedPlaylist === 'All Channels') {
uniqueGroups = new Set(channels.map(channel => channel.group).filter(group => group !== null));
} else {
uniqueGroups = new Set(channels.filter(channel => channel.group !== null && channel.playlistName === selectedPlaylist).map(channel => channel.group));
}
return ['Category', ...Array.from(uniqueGroups)];
}, [selectedPlaylist, channels]);
useEffect(() => {
// Check if admin mode is enabled on the server
apiService
.request<{ enabled: boolean; channelSelectionRequiresAdmin: boolean }>('/auth/admin-status', 'GET')
.then((data) => {
setIsAdminEnabled(data.enabled);
setChannelSelectRequiresAdmin(data.channelSelectionRequiresAdmin);
})
.catch((error) => console.error('Error checking admin status:', error));
apiService
.request<Channel[]>('/channels/', 'GET')
@@ -28,7 +90,6 @@ function App() {
.then((data) => setSelectedChannel(data))
.catch((error) => console.error('Error loading current channel:', error));
console.log('Subscribing to events');
const channelAddedListener = (channel: Channel) => {
setChannels((prevChannels) => [...prevChannels, channel]);
@@ -38,22 +99,86 @@ function App() {
setSelectedChannel(nextChannel);
};
const channelUpdatedListener = (updatedChannel: Channel) => {
setChannels((prevChannels) =>
prevChannels.map((channel) =>
channel.id === updatedChannel.id ?
updatedChannel : channel
)
);
setSelectedChannel((selectedChannel: Channel | null) => {
if (selectedChannel?.id === updatedChannel.id) {
// Reload stream if the stream attributes (url, headers) have changed
if (
(selectedChannel?.url != updatedChannel.url ||
JSON.stringify(selectedChannel?.headers) !=
JSON.stringify(updatedChannel.headers)) &&
selectedChannel?.mode === 'restream'
) {
//TODO: find a better solution instead of reloading (problem is m3u8 needs time to refresh server-side)
setTimeout(() => {
window.location.reload();
}, 3000);
}
return updatedChannel;
}
return selectedChannel;
});
};
const channelDeletedListener = (deletedChannel: number) => {
setChannels((prevChannels) =>
prevChannels.filter((channel) => channel.id !== deletedChannel)
);
};
const errorListener = (error: { message: string }) => {
addToast({
type: 'error',
title: 'Error',
message: error.message,
duration: 5000,
});
};
socketService.subscribeToEvent('channel-added', channelAddedListener);
socketService.subscribeToEvent('channel-selected', channelSelectedListener);
socketService.subscribeToEvent('channel-updated', channelUpdatedListener);
socketService.subscribeToEvent('channel-deleted', channelDeletedListener);
socketService.subscribeToEvent('app-error', errorListener);
socketService.connect();
return () => {
socketService.unsubscribeFromEvent('channel-added', channelAddedListener);
socketService.unsubscribeFromEvent('channel-selected', channelSelectedListener);
socketService.unsubscribeFromEvent(
'channel-selected',
channelSelectedListener
);
socketService.unsubscribeFromEvent(
'channel-updated',
channelUpdatedListener
);
socketService.unsubscribeFromEvent(
'channel-deleted',
channelDeletedListener
);
socketService.unsubscribeFromEvent('app-error', errorListener);
socketService.disconnect();
console.log('WebSocket connection closed');
};
}, []);
const filteredChannels = channels.filter(channel =>
channel.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleEditChannel = (channel: Channel) => {
// Only allow editing if admin mode is not enabled or user is admin
if (!isAdminEnabled || isAdmin) {
setEditChannel(channel);
setIsModalOpen(true);
} else {
setIsAdminModalOpen(true);
}
};
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
@@ -62,6 +187,13 @@ function App() {
<div className="flex items-center space-x-2">
<Radio className="w-8 h-8 text-blue-500" />
<h1 className="text-2xl font-bold">StreamHub</h1>
{isAdmin && (
<span className="ml-2 flex items-center px-2 py-1 text-xs font-medium text-green-400 bg-green-400 bg-opacity-10 rounded-full border border-green-400">
<Shield className="w-3 h-3 mr-1" />
Admin
</span>
)}
</div>
<div className="relative max-w-md w-full">
<input
@@ -75,7 +207,27 @@ function App() {
</div>
<div className="flex items-center space-x-4">
<Users className="w-6 h-6 text-blue-500" />
<button
onClick={() => setIsTvPlaylistOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Tv2 className="w-6 h-6 text-blue-500" />
</button>
<button
onClick={() => setIsSettingsOpen(true)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
>
<Settings className="w-6 h-6 text-blue-500" />
</button>
{isAdminEnabled && (
<button
onClick={() => setIsAdminModalOpen(true)}
className={`p-2 hover:bg-gray-800 rounded-lg transition-colors ${isAdmin ?
"text-green-500" : ""}`}
>
<Shield className="w-6 h-6" />
</button>
)}
</div>
</header>
@@ -83,12 +235,108 @@ function App() {
<div className="col-span-12 lg:col-span-8 space-y-4">
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-4">
<div className="relative">
<button
onClick={() => {
setIsPlaylistDropdownOpen(!isPlaylistDropdownOpen);
setIsGroupDropdownOpen(false);
}}
className="flex items-center space-x-2 group"
>
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">Live Channels</h2>
<h2 className="text-xl font-semibold group-hover:text-blue-400 transition-colors">
{selectedPlaylist}
</h2>
</div>
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ?
"rotate-180" : ""}`} />
</button>
{isPlaylistDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{playlists.map((playlist) => (
<button
onClick={() => setIsModalOpen(true)}
key={playlist}
onClick={() => {
setSelectedPlaylist(playlist);
setSelectedGroup('Category');
setIsPlaylistDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${selectedPlaylist === playlist ?
"text-blue-400 text-base font-semibold" : "text-gray-200"}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{playlist}
</button>
))}
</div>
</div>
)}
</div>
{/* Group Dropdown */}
<div className="relative">
<button
onClick={() => {
setIsGroupDropdownOpen(!isGroupDropdownOpen);
setIsPlaylistDropdownOpen(false);
}}
className="flex items-center space-x-2 group py-0.5 px-1.5 rounded-lg transition-all bg-white bg-opacity-10"
>
<div className="flex items-center space-x-2">
<h4 className="text-base text-gray-300 group-hover:text-blue-400 transition-colors">
{selectedGroup}
</h4>
</div>
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ?
"rotate-180" : ""}`} />
</button>
{isGroupDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{groups.map((group) => (
<button
key={group}
onClick={() => {
setSelectedGroup(group);
setIsGroupDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${selectedGroup === group ?
"text-blue-400 text-base font-semibold" : "text-gray-200"}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{group === 'Category' ? 'All Categories' : group}
</button>
))}
</div>
</div>
)}
</div>
</div>
<button
onClick={() => {
// Only allow adding channels if admin mode is not enabled or user is admin
if (!isAdminEnabled || isAdmin) {
setIsModalOpen(true);
setIsGroupDropdownOpen(false);
setIsPlaylistDropdownOpen(false);
} else {
setIsAdminModalOpen(true);
}
}}
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
@@ -99,10 +347,18 @@ function App() {
channels={filteredChannels}
selectedChannel={selectedChannel}
setSearchQuery={setSearchQuery}
onEditChannel={handleEditChannel}
onChannelSelectCheckPermission={() => {
if (isAdminEnabled && channelSelectRequiresAdmin && !isAdmin) {
setIsAdminModalOpen(true);
return false;
}
return true;
}}
/>
</div>
<VideoPlayer channel={selectedChannel} />
<VideoPlayer channel={selectedChannel} syncEnabled={syncEnabled} />
</div>
<div className="col-span-12 lg:col-span-4">
@@ -111,12 +367,49 @@ function App() {
</div>
</div>
<AddChannelModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
{isModalOpen && (
<ChannelModal
onClose={() => {
setIsModalOpen(false);
setEditChannel(null);
}}
channel={editChannel}
/>
)}
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
syncEnabled={syncEnabled}
onSyncChange={(enabled) => {
setSyncEnabled(enabled);
localStorage.setItem('syncEnabled', JSON.stringify(enabled));
}}
/>
<TvPlaylistModal
isOpen={isTvPlaylistOpen}
onClose={() => setIsTvPlaylistOpen(false)}
/>
<AdminModal
isOpen={isAdminModalOpen}
onClose={() => setIsAdminModalOpen(false)}
/>
<ToastContainer />
</div>
);
}
function App() {
return (
<ToastProvider>
<AdminProvider>
<AppContent />
</AdminProvider>
</ToastProvider>
);
}
export default App;

View File

@@ -1,135 +0,0 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import socketService from '../services/SocketService';
interface AddChannelModalProps {
isOpen: boolean;
onClose: () => void;
}
function AddChannelModal({ isOpen, onClose}: AddChannelModalProps) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [avatar, setAvatar] = useState('');
const [restream, setRestream] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !url.trim()) return;
socketService.addChannel(name.trim(), url.trim(), avatar.trim() || 'https://via.placeholder.com/64', restream);
setName('');
setUrl('');
setAvatar('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-semibold">Add New Channel</h2>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Channel Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter channel name"
/>
</div>
<div>
<label htmlFor="url" className="block text-sm font-medium mb-1">
Stream URL
</label>
<input
type="url"
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter stream URL"
/>
</div>
<div>
<label htmlFor="url" className="block text-sm font-medium mb-1">
Avatar URL
</label>
<input
type="url"
id="avatar"
value={avatar}
onChange={(e) => setAvatar(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter channel avatar URL"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Restream through backend</label>
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="radio"
name="restream"
value="yes"
checked={restream}
className="form-radio text-blue-600"
onChange={() => setRestream(true)}
/>
<span className="ml-2">Yes</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="restream"
value="no"
className="form-radio text-blue-600"
checked={!restream}
onChange={() => setRestream(false)}
/>
<span className="ml-2">No</span>
</label>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
Add Channel
</button>
</div>
</form>
</div>
</div>
);
}
export default AddChannelModal;

View File

@@ -1,30 +1,47 @@
import React from 'react';
import { Channel } from '../types';
import socketService from '../services/SocketService';
import React from "react";
import { Channel } from "../types";
import socketService from "../services/SocketService";
interface ChannelListProps {
channels: Channel[];
selectedChannel: Channel | null;
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
onEditChannel: (channel: Channel) => void;
onChannelSelectCheckPermission: () => boolean;
}
function ChannelList({ channels, selectedChannel, setSearchQuery }: ChannelListProps) {
function ChannelList({
channels,
selectedChannel,
setSearchQuery,
onEditChannel,
onChannelSelectCheckPermission,
}: ChannelListProps) {
const onSelectChannel = (channel: Channel) => {
setSearchQuery('');
setSearchQuery("");
if (channel.id === selectedChannel?.id) return;
if (!onChannelSelectCheckPermission()) return;
socketService.setCurrentChannel(channel.id);
};
const onRightClickChannel = (event: React.MouseEvent, channel: Channel) => {
event.preventDefault();
onEditChannel(channel);
};
return (
<div className="flex space-x-3 hover:overflow-x-auto overflow-hidden pb-2 px-1 pt-1 scroll-container">
{channels.map((channel) => (
<button
key={channel.id}
title={channel.name.length > 28 ? channel.name : ""}
onClick={() => onSelectChannel(channel)}
onContextMenu={(event) => onRightClickChannel(event, channel)}
className={`group relative p-2 rounded-lg transition-all ${
selectedChannel?.id === channel.id
? 'bg-blue-500 bg-opacity-20 ring-2 ring-blue-500'
: 'hover:bg-gray-700'
? "bg-blue-500 bg-opacity-20 ring-2 ring-blue-500"
: "hover:bg-gray-700"
}`}
>
<div className="h-20 w-20 mb-2 flex items-center justify-center rounded-lg mx-auto">
@@ -34,7 +51,11 @@ function ChannelList({ channels, selectedChannel, setSearchQuery }: ChannelListP
className="w-full h-full object-contain rounded-lg transition-transform group-hover:scale-105"
/>
</div>
<p className="text-sm font-medium truncate text-center">{channel.name}</p>
<p className="text-sm font-medium truncate text-center">
{channel.name.length > 28
? `${channel.name.substring(0, 28)}...`
: channel.name}
</p>
</button>
))}
</div>

View File

@@ -0,0 +1,51 @@
import { X, Clock } from 'lucide-react';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
syncEnabled: boolean;
onSyncChange: (enabled: boolean) => void;
}
function SettingsModal({ isOpen, onClose, syncEnabled, onSyncChange }: SettingsModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-semibold">Settings</h2>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Clock className="w-5 h-5 text-blue-400" />
<div>
<h3 className="font-medium">Stream Synchronization</h3>
<p className="text-sm text-gray-400">Keep stream playback in sync with your friends. This causes longer loading times. </p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={syncEnabled}
onChange={(e) => onSyncChange(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500"></div>
</label>
</div>
</div>
</div>
</div>
);
}
export default SettingsModal;

View File

@@ -0,0 +1,34 @@
import { Info } from 'lucide-react';
interface TooltipProps {
content: React.ReactNode;
}
export function Tooltip({ content }: TooltipProps) {
return (
<div className="group relative inline-flex items-center">
<Info className="w-4 h-4 text-blue-400 cursor-help" />
<div className="absolute left-0 bottom-full mb-2 w-96 bg-gray-900 p-3 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
{content}
<div className="absolute left-1 bottom-[-6px] w-3 h-3 bg-gray-900 transform rotate-45"></div>
</div>
</div>
);
}
export const ModeTooltipContent = () => (
<div className="space-y-3 text-sm">
<div>
<span className="font-semibold text-blue-400">Direct</span>
<p>Directly uses the source stream. Won't work with most of the streams, because of CORS, IP/Device restrictions. Is also incompatible with custom headers and privacy mode.</p>
</div>
<div>
<span className="font-semibold text-blue-400">Proxy</span>
<p>The stream requests are proxied through the backend. Allows to set custom headers and bypass CORS. This mode is preffered. Only switch to restream mode, if proxy mode won't work for your stream or if you have synchronization issues.</p>
</div>
<div>
<span className="font-semibold text-blue-400">Restream</span>
<p>The backend service caches the source stream (with ffmpeg) and restreams it. Can help with hard device restrictions of your provider. But it can lead to long initial loading times and performance issues after time.</p>
</div>
</div>
);

View File

@@ -0,0 +1,93 @@
import { X, Copy, Tv2 } from 'lucide-react';
import { useContext } from 'react';
import { ToastContext } from './notifications/ToastContext';
import { useAdmin } from './admin/AdminContext';
interface TvPlaylistModalProps {
isOpen: boolean;
onClose: () => void;
}
function TvPlaylistModal({ isOpen, onClose }: TvPlaylistModalProps) {
const { isAdmin } = useAdmin();
const { addToast } = useContext(ToastContext);
const playlistUrl = `${import.meta.env.VITE_BACKEND_URL || window.location.origin}/api/channels/playlist`;
if (!isOpen) return null;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(playlistUrl);
addToast({
type: 'success',
title: 'Playlist URL copied to clipboard',
duration: 2500,
});
} catch (err) {
addToast({
type: 'error',
title: 'Failed to copy URL',
message: 'Please copy the URL manually',
duration: 2500,
});
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg w-full max-w-2xl">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">TV Playlist</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center space-x-2">
<input
type="text"
value={playlistUrl}
readOnly
className="flex-1 bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleCopy}
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Copy className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-gray-400">
Use this playlist in any other IPTV player. If you have problems, check if the base-url in the playlist is correctly pointing to the backend. If not, please set BACKEND_URL in the docker-compose.yml
</p>
{isAdmin && (
<div className="mt-6 border-t border-gray-700 pt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-medium">Admin Information</h3>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<p className="text-sm text-gray-300">
This playlist contains all stream URLs. You can share a link to the
application with other users, and they will be able to watch the streams
together with you.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default TvPlaylistModal;

View File

@@ -1,14 +1,17 @@
import React, { useEffect, useRef } from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import Hls from 'hls.js';
import { Channel } from '../types';
import { Channel, ChannelMode } from '../types';
import { ToastContext } from './notifications/ToastContext';
interface VideoPlayerProps {
channel: Channel | null;
syncEnabled: boolean;
}
function VideoPlayer({ channel }: VideoPlayerProps) {
function VideoPlayer({ channel, syncEnabled }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
const { addToast, removeToast, clearToasts, editToast } = useContext(ToastContext);
useEffect(() => {
if (!videoRef.current || !channel?.url) return;
@@ -20,8 +23,9 @@ function VideoPlayer({ channel }: VideoPlayerProps) {
}
const hls = new Hls({
autoStartLoad: false,
autoStartLoad: syncEnabled ? false : true,
liveDurationInfinity: true,
//debug: true,
manifestLoadPolicy: {
default: {
maxTimeToFirstByteMs: Infinity,
@@ -32,8 +36,8 @@ function VideoPlayer({ channel }: VideoPlayerProps) {
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 20,
retryDelayMs: 1500,
maxNumRetry: 12,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
backoff: 'linear',
shouldRetry: (
@@ -47,64 +51,108 @@ function VideoPlayer({ channel }: VideoPlayerProps) {
},
});
const sourceLinks: Record<ChannelMode, string> = {
direct: channel.url,
//TODO: needs update for multi-channel streaming
proxy: import.meta.env.VITE_BACKEND_URL + '/proxy/channel',
restream: import.meta.env.VITE_BACKEND_URL + '/streams/' + channel.id + "/" + channel.id + ".m3u8", //e.g. http://backend:3000/streams/1/1.m3u8
};
hlsRef.current = hls;
hls.loadSource(channel.restream ? import.meta.env.VITE_BACKEND_URL + import.meta.env.VITE_BACKEND_STREAMS_PATH : channel.url);
hls.loadSource(sourceLinks[channel.mode]);
hls.attachMedia(video);
// Synchronization settings
//TODO: extract to config
const tolerance = 0.8;
const maxDeviation = 4;
if(!syncEnabled) return;
clearToasts();
let toastStartId = null;
toastStartId = addToast({
type: 'loading',
title: 'Starting Stream',
message: 'This might take a few moments...',
duration: 0,
});
const tolerance = import.meta.env.VITE_SYNCHRONIZATION_TOLERANCE || 0.8;
const maxDeviation = import.meta.env.VITE_SYNCHRONIZATION_MAX_DEVIATION || 4;
let toastDurationSet = false;
hls.on(Hls.Events.MANIFEST_PARSED, (_event, _data) => {
if(channel.restream) {
// Wait for the stream to load and play
const interval = setInterval(() => {
if (channel.mode === 'restream') {
const now = new Date().getTime();
const fragments = hls.levels[0]?.details?.fragments;
const lastFragment = fragments?.[fragments.length - 1];
if (!lastFragment || !lastFragment.programDateTime) return;
if (!lastFragment || !lastFragment.programDateTime) {
console.warn("No program date time found in fragment. Cannot synchronize.");
return;
}
const timeDiff = (now - lastFragment.programDateTime) / 1000;
const videoLength = fragments.reduce((acc, fragment) => {
return acc + fragment.duration;
}, 0);
const targetDelay = import.meta.env.VITE_STREAM_DELAY;
const videoLength = fragments.reduce((acc, fragment) => acc + fragment.duration, 0);
const targetDelay : number = Number(import.meta.env.VITE_STREAM_DELAY);
// It takes some time for the stream to load and play. Estimated here: 1s
//Load stream if it is close to the target delay
const timeTolerance = tolerance + 1;
if (videoLength + timeDiff + timeTolerance >= targetDelay) {
const delay : number = videoLength + timeDiff + timeTolerance;
if (delay >= targetDelay) {
hls.startLoad();
video.play();
clearInterval(interval);
console.log("Starting stream");
if (!toastDurationSet && toastStartId) {
removeToast(toastStartId);
}
} else {
console.log("Waiting for stream to load: ", delay, " < ", targetDelay);
if(!toastDurationSet && toastStartId) {
editToast(toastStartId, {duration: (1 + targetDelay - delay) * 1000});
toastDurationSet = true;
}
// Reload manifest
setTimeout(() => {
hls.loadSource(import.meta.env.VITE_BACKEND_URL + '/streams/' + channel.id + "/" + channel.id + ".m3u8");
}, 1000);
}
} else {
hls.startLoad();
video.play();
}
if (toastStartId) {
removeToast(toastStartId);
}
}
});
let timeMissingErrorShown = false;
hls.on(Hls.Events.FRAG_LOADED, (_event, data) => {
const now = new Date().getTime();
const newFrag = data.frag;
if(!newFrag.programDateTime) return;
if(!newFrag.programDateTime) {
if(!timeMissingErrorShown) {
addToast({
type: 'error',
title: 'Synchronization Error',
message: `Playback can't be synchonized for this channel in ${channel.mode}. Change this channel to restream mode and try again.`,
duration: 5000,
});
console.warn("No program date time found in fragment. Cannot synchronize.");
timeMissingErrorShown = true;
}
return;
}
const timeDiff = (now - newFrag.programDateTime) / 1000;
const videoDiff = newFrag.end - video.currentTime;
console.log("Time Diff: ", timeDiff, "Video Diff: ", videoDiff);
//console.log("Time Diff: ", timeDiff, "Video Diff: ", videoDiff);
const delay = timeDiff + videoDiff;
const targetDelay = import.meta.env.VITE_STREAM_DELAY;
console.log("Delay: ", delay, "Target Delay: ", targetDelay);
const targetDelay = channel.mode == 'restream' ? import.meta.env.VITE_STREAM_DELAY : import.meta.env.VITE_STREAM_PROXY_DELAY;
// console.log("Delay: ", delay, "Target Delay: ", targetDelay);
const deviation = delay - targetDelay;
@@ -112,14 +160,55 @@ function VideoPlayer({ channel }: VideoPlayerProps) {
video.currentTime += deviation;
video.playbackRate = 1.0;
console.log("Significant deviation detected. Adjusting current time.");
// TODO
// console.log("New Time: ", video.currentTime, "New Frag: ", newFrag.end);
// if(video.paused) {
// console.warn("[Synchronization Issue] Video stopped. Switch to Restream Mode for this channel");
// deviationErrorCount++;
// if(deviationErrorCount > 2) {
// addToast({
// type: 'error',
// title: 'Synchronization Error',
// message: `Having problems synchronizing playback for the channel in mode: ${channel.mode}. Try to change to restream mode or turn off synchronization.`,
// duration: 5000,
// });
// }
// }
} else if (Math.abs(deviation) > tolerance) {
const adjustmentFactor = 0.08;
const speedAdjustment = 1 + adjustmentFactor * deviation;
const adjustmentFactor = import.meta.env.VITE_SYNCHRONIZATION_ADJUSTMENT || 0.06;
const speedAdjustment = 1 + Math.sign(deviation) * Math.min(Math.abs(adjustmentFactor * deviation), import.meta.env.VITE_SYNCHRONIZATION_MAX_ADJUSTMENT || 0.16);
video.playbackRate = speedAdjustment;
} else {
video.playbackRate = 1.0;
}
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
console.error('HLS error:', data);
if (toastStartId) {
removeToast(toastStartId);
}
const messages: Record<ChannelMode, string> = {
direct: 'The stream is not working. Try with proxy/restream option enabled for this channel.',
proxy: 'The stream is not working. Try with restream option enabled for this channel.',
restream: `The stream is not working. Check the source. ${data.response?.text}`,
};
addToast({
type: 'error',
title: 'Stream Error',
message: messages[channel.mode],
duration: 5000,
});
return;
}
});
}
return () => {
@@ -127,7 +216,8 @@ function VideoPlayer({ channel }: VideoPlayerProps) {
hlsRef.current.destroy();
}
};
}, [channel?.url]);
}, [channel?.url, channel?.mode, syncEnabled]);
const handleVideoClick = (event: React.MouseEvent<HTMLVideoElement>) => {
if (videoRef.current?.muted) {
@@ -149,6 +239,14 @@ function VideoPlayer({ channel }: VideoPlayerProps) {
controls
onClick={handleVideoClick}
/>
<div className="flex items-center p-4 bg-gray-900 text-white">
<img
src={channel?.avatar}
alt={`${channel?.name} avatar`}
className="w-10 h-10 object-contain mr-3"
/>
<span className="font-medium">{channel?.name}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,510 @@
import React, { useState, useEffect, useContext } from 'react';
import { Plus, Trash2, X } from 'lucide-react';
import socketService from '../../services/SocketService';
import { CustomHeader, Channel, ChannelMode } from '../../types';
import CustomHeaderInput from './CustomHeaderInput';
import { ToastContext } from '../notifications/ToastContext';
import { ModeTooltipContent, Tooltip } from '../Tooltip';
interface ChannelModalProps {
onClose: () => void;
channel?: Channel | null;
}
function ChannelModal({ onClose, channel }: ChannelModalProps) {
const [type, setType] = useState<'channel' | 'playlist'>('playlist');
const [isEditMode, setIsEditMode] = useState(false);
const [inputMethod, setInputMethod] = useState<'url' | 'text'>('url');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [avatar, setAvatar] = useState('');
const [mode, setMode] = useState<ChannelMode>('proxy');
const [headers, setHeaders] = useState<CustomHeader[]>([]);
const [playlistName, setPlaylistName] = useState('');
const [playlistUrl, setPlaylistUrl] = useState('');
const [playlistText, setPlaylistText] = useState('');
const [playlistUpdate, setPlaylistUpdate] = useState(false);
const { addToast } = useContext(ToastContext);
useEffect(() => {
if (channel) {
setName(channel.name);
setUrl(channel.url);
setAvatar(channel.avatar);
setMode(channel.mode);
setHeaders(channel.headers);
setPlaylistName(channel.playlistName);
setPlaylistUpdate(channel.playlistUpdate);
setIsEditMode(true);
setType('channel');
if(!channel.playlist) {
setInputMethod('url');
setPlaylistUrl('');
setPlaylistText('');
} else if(channel.playlist.startsWith("http")) {
setInputMethod('url');
setPlaylistUrl(channel.playlist);
setPlaylistText('');
} else {
setInputMethod('text');
setPlaylistUrl('');
setPlaylistText(channel.playlist);
}
} else {
setName('');
setUrl('');
setAvatar('');
setMode('proxy');
setHeaders([]);
setPlaylistName('');
setPlaylistUrl('');
setPlaylistText('');
setPlaylistUpdate(false);
setIsEditMode(false);
setType('playlist');
setInputMethod('url');
}
}, [channel]);
const addHeader = () => {
setHeaders([...headers, { key: '', value: '' }]);
};
const removeHeader = (index: number) => {
setHeaders(headers.filter((_, i) => i !== index));
};
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
const newHeaders = [...headers];
newHeaders[index] = { ...newHeaders[index], [field]: value };
setHeaders(newHeaders);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isEditMode && channel) {
handleUpdate(channel.id);
return;
}
if (type === 'channel') {
if (!name.trim() || !url.trim()) return;
socketService.addChannel(
name.trim(),
url.trim(),
avatar.trim() || 'https://via.placeholder.com/64',
mode,
JSON.stringify(headers),
);
} else if (type === 'playlist') {
if (inputMethod === 'url' && !playlistUrl.trim()) return;
if (inputMethod === 'text' && !playlistText.trim()) return;
socketService.addPlaylist(
inputMethod === 'url' ? playlistUrl.trim() : playlistText.trim(),
playlistName.trim(),
mode,
playlistUpdate,
JSON.stringify(headers),
);
}
addToast({
type: 'success',
title: `${type} added`,
duration: 3000,
});
onClose();
};
const handleUpdate = (id: number) => {
if (type === 'channel') {
socketService.updateChannel(id, {
name: name.trim(),
url: url.trim(),
avatar: avatar.trim() || 'https://via.placeholder.com/64',
mode: mode,
headers: headers,
});
} else if (type === 'playlist') {
const newPlaylist = inputMethod === 'url' ? playlistUrl.trim() : playlistText.trim();
socketService.updatePlaylist(channel!.playlist, {
playlist: newPlaylist,
playlistName: playlistName.trim(),
playlistUpdate: playlistUpdate,
mode: mode,
headers: headers,
});
}
addToast({
type: 'success',
title: `${type} updated`,
duration: 3000,
});
onClose();
};
const handleDelete = () => {
if (channel) {
addToast({
type: "error",
title: `Deleting ${type}`,
duration: 3000,
});
if (type === 'channel') {
socketService.deleteChannel(channel.id);
} else if (type === 'playlist') {
socketService.deletePlaylist(channel.playlist);
}
}
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-semibold">
{isEditMode ? (type === 'channel' ? 'Edit Channel' : 'Edit Playlist') : type === 'channel' ? 'Add New Channel' : 'Add New Playlist'}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{(!isEditMode || channel?.playlist) && (
<div className="p-4 pb-0">
<div className="flex space-x-4 justify-center">
<button
onClick={() => setType('channel')}
className={`px-4 py-2 rounded-lg border-2 ${type === 'channel' ? 'border-blue-600' : 'border-transparent'} hover:border-blue-600 transition-colors`}
>
Channel
</button>
<button
onClick={() => setType('playlist')}
className={`px-4 py-2 rounded-lg border-2 ${type === 'playlist' ? 'border-blue-600' : 'border-transparent'} hover:border-blue-600 transition-colors`}
>
Playlist
</button>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{type === 'channel' && (
<>
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Channel Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter channel name"
required
/>
</div>
<div>
<div className="flex justify-between items-center mb-1">
<label htmlFor="url" className="block text-sm font-medium">
Stream URL
</label>
</div>
<input
type="url"
id="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter stream URL"
required
/>
</div>
<div>
<label htmlFor="avatar" className="block text-sm font-medium mb-1">
Avatar URL
</label>
<input
type="url"
id="avatar"
value={avatar}
onChange={(e) => setAvatar(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter channel avatar URL"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
<span className="inline-flex items-center gap-2">
Channel Mode
<Tooltip content={<ModeTooltipContent />} />
</span>
</label>
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="radio"
name="mode"
value="direct"
checked={mode === 'direct'}
className="form-radio text-blue-600"
onChange={() => setMode('direct')}
/>
<span className="ml-2">Direct</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mode"
value="proxy"
className="form-radio text-blue-600"
checked={mode === 'proxy'}
onChange={() => setMode('proxy')}
/>
<span className="ml-2">Proxy</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mode"
value="restream"
className="form-radio text-blue-600"
checked={mode === 'restream'}
onChange={() => setMode('restream')}
/>
<span className="ml-2">Restream</span>
</label>
</div>
</div>
</>
)}
{type === 'playlist' && (
<>
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Playlist Name
</label>
<input
type="text"
id="playlistName"
value={playlistName}
onChange={(e) => setPlaylistName(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter playlist name"
required
/>
</div>
{inputMethod === 'url' ? (
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor="playlistUrl" className="block text-sm font-medium">
<button
type="button"
onClick={() => setInputMethod('url')}
className="font-bold text-white"
>
M3U Playlist URL
</button>
<span className="mx-2 text-gray-400">/</span>
<button
type="button"
onClick={() => setInputMethod('text')}
className="text-gray-400 hover:text-gray-200"
>
M3U Text
</button>
</label>
</div>
<input
type="url"
id="playlistUrl"
value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter M3U playlist URL"
required={inputMethod === 'url'}
/>
</div>
) : (
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor="playlistText" className="block text-sm font-medium">
<button
type="button"
onClick={() => setInputMethod('url')}
className="text-gray-400 hover:text-gray-200"
>
M3U Playlist URL
</button>
<span className="mx-2 text-gray-400">/</span>
<button
type="button"
onClick={() => setInputMethod('text')}
className="font-bold text-white"
>
M3U Text
</button>
</label>
</div>
<textarea
id="playlistText"
value={playlistText}
onChange={(e) => setPlaylistText(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[200px] scroll-container overflow-y-auto"
placeholder="#EXTM3U..."
required={inputMethod === 'text'}
style={{ resize: 'none' }}
/>
</div>
)}
<div>
<label className="block text-sm font-medium mb-1">
<span className="inline-flex items-center gap-2">
Channel Mode
<Tooltip content={<ModeTooltipContent />} />
</span>
</label>
<div className="flex items-center space-x-4">
<label className="flex items-center">
<input
type="radio"
name="mode"
value="direct"
checked={mode === 'direct'}
className="form-radio text-blue-600"
onChange={() => setMode('direct')}
/>
<span className="ml-2">Direct</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mode"
value="proxy"
className="form-radio text-blue-600"
checked={mode === 'proxy'}
onChange={() => setMode('proxy')}
/>
<span className="ml-2">Proxy</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="mode"
value="restream"
className="form-radio text-blue-600"
checked={mode === 'restream'}
onChange={() => setMode('restream')}
/>
<span className="ml-2">Restream</span>
</label>
</div>
</div>
{/* Playlist auto-update toggle */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium">Playlist Auto Update</label>
<p className="text-sm text-gray-400">Automatically update playlist once a day</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={playlistUpdate}
onChange={(e) => setPlaylistUpdate(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500"></div>
</label>
</div>
</>
)}
{mode !== 'direct' && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">
Custom Headers
</label>
<div className="flex items-center space-x-2">
<button
type="button"
onClick={addHeader}
className="flex items-center space-x-1 text-sm text-blue-400 hover:text-blue-300"
>
<Plus className="w-4 h-4" />
<span>Add Header</span>
</button>
</div>
</div>
<div className="space-y-2">
{headers && headers.map((header, index) => (
<div key={index} className="flex items-center space-x-2">
<CustomHeaderInput
header={header}
onKeyChange={(value) => updateHeader(index, 'key', value)}
onValueChange={(value) => updateHeader(index, 'value', value)}
/>
<button
type="button"
onClick={() => removeHeader(index)}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end space-x-3">
{isEditMode && (
<button
type="button"
onClick={handleDelete}
className="px-4 py-2 bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Delete
</button>
)}
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{isEditMode ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
);
}
export default ChannelModal;

View File

@@ -0,0 +1,30 @@
import { CustomHeader } from '../../types';
interface CustomHeaderInputProps {
header: CustomHeader;
onKeyChange: (value: string) => void;
onValueChange: (value: string) => void;
}
function CustomHeaderInput({ header, onKeyChange, onValueChange }: CustomHeaderInputProps) {
return (
<div className="flex-1 grid grid-cols-2 gap-2">
<input
type="text"
value={header.key}
onChange={(e) => onKeyChange(e.target.value)}
placeholder="Header name"
className="bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<input
type="text"
value={header.value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="Header value"
className="bg-gray-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
);
}
export default CustomHeaderInput;

View File

@@ -0,0 +1,99 @@
import React, {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
import { jwtDecode } from 'jwt-decode';
import socketService from '../../services/SocketService';
interface AdminContextType {
isAdmin: boolean | null;
setIsAdmin: (value: boolean) => void;
isAdminEnabled: boolean;
setIsAdminEnabled: (value: boolean) => void;
channelSelectRequiresAdmin: boolean;
setChannelSelectRequiresAdmin: (value: boolean) => void;
adminToken: string | null;
}
const AdminContext = createContext<AdminContextType>({
isAdmin: false,
setIsAdmin: () => {},
isAdminEnabled: false,
setIsAdminEnabled: () => {},
channelSelectRequiresAdmin: false,
setChannelSelectRequiresAdmin: () => {},
adminToken: null,
});
export const useAdmin = () => useContext(AdminContext);
interface AdminProviderProps {
children: ReactNode;
}
// Helper function to check if token is valid
const isTokenValid = (token: string): boolean => {
try {
const decoded: any = jwtDecode(token);
// Check if token is expired
return decoded.exp * 1000 > Date.now();
} catch {
return false;
}
};
export const AdminProvider: React.FC<AdminProviderProps> = ({ children }) => {
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isAdminEnabled, setIsAdminEnabled] = useState(false);
const [channelSelectRequiresAdmin, setChannelSelectRequiresAdmin] = useState(false);
const [adminToken, setAdminToken] = useState<string | null>(null);
// Effect to handle token changes
useEffect(() => {
// When admin status changes, update socket connection
if (isAdmin === true) {
// Small delay to ensure token is saved before reconnecting
setTimeout(() => {
socketService.updateAuthToken();
}, 100);
} else if (isAdmin === false) {
// Reset token and reconnect
localStorage.removeItem("admin_token");
setAdminToken(null);
socketService.updateAuthToken();
}
}, [isAdmin]);
// Initial setup - check for existing token
useEffect(() => {
// Check if there's a token in localStorage on component mount
const token = localStorage.getItem('admin_token');
if (token && isTokenValid(token)) {
setIsAdmin(true);
setAdminToken(token);
} else if (token) {
// Clear invalid token
setIsAdmin(false);
}
}, []);
return (
<AdminContext.Provider
value={{
isAdmin,
setIsAdmin,
isAdminEnabled,
setIsAdminEnabled,
channelSelectRequiresAdmin,
setChannelSelectRequiresAdmin,
adminToken,
}}
>
{children}
</AdminContext.Provider>
);
};

View File

@@ -0,0 +1,130 @@
import React, { useState, useContext } from 'react';
import { X, Shield, ShieldOff } from 'lucide-react';
import { ToastContext } from '../notifications/ToastContext';
import { useAdmin } from './AdminContext';
import apiService from '../../services/ApiService';
interface AdminModalProps {
isOpen: boolean;
onClose: () => void;
}
function AdminModal({ isOpen, onClose }: AdminModalProps) {
const [password, setPassword] = useState('');
const { isAdmin, setIsAdmin } = useAdmin();
const { addToast } = useContext(ToastContext);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await apiService.request<{success: boolean, token?: string}>('/auth/admin-login', 'POST', undefined, {
password
});
if (response.success && response.token) {
// Store JWT token in localStorage
localStorage.setItem('admin_token', response.token);
setIsAdmin(true);
addToast({
type: 'success',
title: 'Admin mode enabled',
duration: 3000,
});
onClose();
} else {
addToast({
type: 'error',
title: 'Invalid password',
duration: 3000,
});
}
} catch (error) {
addToast({
type: 'error',
title: 'Authentication failed',
message: 'Please try again',
duration: 3000,
});
}
};
const handleLogout = () => {
// Remove JWT token from localStorage
localStorage.removeItem('admin_token');
setIsAdmin(false);
addToast({
type: 'info',
title: 'Admin mode disabled',
duration: 3000,
});
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<div className="flex items-center space-x-2">
{isAdmin ? (
<Shield className="w-5 h-5 text-green-500" />
) : (
<ShieldOff className="w-5 h-5 text-blue-500" />
)}
<h2 className="text-xl font-semibold">Admin Mode</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
{isAdmin ? (
<div className="space-y-4">
<p className="text-green-500">You are currently in admin mode.</p>
<button
onClick={handleLogout}
className="w-full p-2 bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Logout from Admin Mode
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="adminPassword" className="block text-sm font-medium mb-1">
Admin Password
</label>
<input
type="password"
id="adminPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter admin password"
required
/>
</div>
<button
type="submit"
className="w-full p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
Login
</button>
</form>
)}
</div>
</div>
</div>
);
}
export default AdminModal;

View File

@@ -74,7 +74,7 @@ function Chat() {
};
return (
<div className="bg-gray-800 rounded-lg h-full">
<div className="bg-gray-800 rounded-lg">
<div className="flex items-center space-x-2 p-4 border-b border-gray-700">
<MessageSquare className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">Live Chat</h2>

View File

@@ -0,0 +1,81 @@
import { AlertCircle, CheckCircle, Info, Loader, X } from 'lucide-react';
import { useContext } from 'react';
import { ToastContext } from './ToastContext';
function ToastContainer() {
const { toasts, removeToast } = useContext(ToastContext);
return (
<div className="fixed top-4 right-4 z-50 space-y-4 min-w-[320px] max-w-[420px]">
{toasts.map((toast) => {
const icons = {
info: <Info className="w-5 h-5 text-blue-400" />,
success: <CheckCircle className="w-5 h-5 text-green-400" />,
error: <AlertCircle className="w-5 h-5 text-red-400" />,
loading: <Loader className="w-5 h-5 text-blue-400 animate-spin" />,
};
return (
<div
key={toast.id}
className="bg-gray-800 rounded-lg shadow-lg overflow-hidden transform transition-all ease-in-out opacity-100"
>
{/* Toast Content */}
<div className="p-4">
<div className="flex items-start justify-between space-x-3">
<div className="flex-shrink-0">{icons[toast.type]}</div>
<div className="flex-1 pt-0.5">
<h3 className="font-medium text-gray-100">{toast.title}</h3>
{toast.message && (
<p className="mt-1 text-sm text-gray-300">{toast.message}</p>
)}
</div>
{/* Close Button */}
<button
className="text-gray-400 hover:text-gray-100 focus:outline-none"
onClick={() => removeToast(toast.id)}
aria-label="Close toast"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Progress Bar */}
{toast.duration != 0 && (
<div className="h-1 bg-gray-700 relative">
<div
className="absolute top-0 right-0 h-full"
style={{
backgroundColor:
toast.type === 'error'
? 'rgb(239 68 68)' // Tailwind's `bg-red-500`
: toast.type === 'success'
? 'rgb(34 197 94)' // Tailwind's `bg-green-500`
: 'rgb(59 130 246)', // Tailwind's `bg-blue-500`
width: '100%',
animation: `shrink ${toast.duration}ms linear`,
}}
onAnimationEnd={() => removeToast(toast.id)}
/>
</div>
)}
</div>
);
})}
{/* Add the keyframes for the shrink animation */}
<style>{`
@keyframes shrink {
from {
width: 100%;
}
to {
width: 0%;
}
}
`}</style>
</div>
);
}
export default ToastContainer;

View File

@@ -0,0 +1,63 @@
import React, { createContext, useCallback, useState } from 'react';
import { ToastNotification } from '../../types';
interface ToastContextType {
addToast: (toast: Omit<ToastNotification, 'id'>) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
editToast: (id: string, newToast: Partial<Omit<ToastNotification, 'id'>>) => void;
toasts: ToastNotification[];
}
export const ToastContext = createContext<ToastContextType>({
addToast: () => '',
removeToast: () => {},
clearToasts: () => {},
editToast: () => {},
toasts: [],
});
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback(
({ type, title, message, duration = 5000 }: Omit<ToastNotification, 'id'>) => {
const id = Math.random().toString(36).substring(2, 9);
const newToast: ToastNotification = {
id,
type,
title,
message,
duration,
};
setToasts((prevToasts) => [...prevToasts, newToast]);
return id;
},
[]
);
const editToast = useCallback(
(id: string, newToast: Partial<Omit<ToastNotification, 'id'>>) => {
setToasts((prevToasts) =>
prevToasts.map((toast) => (toast.id === id ? { ...toast, ...newToast } : toast))
);
},
[]
);
const removeToast = useCallback((id: string) => {
setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id));
}, []);
const clearToasts = useCallback(() => {
setToasts([]);
}, []);
return (
<ToastContext.Provider value={{ addToast, removeToast, clearToasts, editToast, toasts }}>
{children}
</ToastContext.Provider>
);
}

View File

@@ -3,5 +3,5 @@ import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render(
<App />,
<App />
)

View File

@@ -4,11 +4,12 @@ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
const apiService = {
/**
* Execute API request
* Execute API request with JWT auth token (if available)
* @param path - Path (e.g. "/channels/")
* @param method - HTTP-Method (GET, POST, etc.)
* @param api_url - The API URL (default: API_BASE_URL + '/api')
* @param body - The request body (e.g. POST)
* @returns Ein Promise with the parsed JSON response to class T
* @returns A Promise with the parsed JSON response to class T
*/
async request<T>(path: string, method: HttpMethod = 'GET', api_url: string = API_BASE_URL + '/api', body?: unknown): Promise<T> {
try {
@@ -16,9 +17,15 @@ const apiService = {
method,
headers: {
'Content-Type': 'application/json',
},
} as Record<string, string>,
};
// Add Authorization header if JWT token exists
const token = localStorage.getItem('admin_token');
if (token) {
(options.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
if (body) {
options.body = JSON.stringify(body);
}

View File

@@ -1,31 +1,75 @@
import { io, Socket } from 'socket.io-client';
import { ChannelMode } from '../types';
class SocketService {
private socket: Socket | null = null;
private listeners: Map<string, ((data: any) => void)[]> = new Map();
private isConnecting: boolean = false;
private token: string | null = null;
// Initialize
// Initialize connection with JWT token if available
connect() {
if (this.socket?.connected) return;
// Get JWT token from localStorage
const newToken = localStorage.getItem('admin_token');
console.log('Connecting to WebSocket server: ');
// Default Behavior: If 'VITE_BACKEND_URL' is not set, the app will use the same host name as the frontend
this.socket = io(import.meta.env.VITE_BACKEND_URL);
// If already connected with the same token, don't reconnect
if (this.socket?.connected && this.token === newToken) {
return;
}
// If connecting with the same token, don't try to connect again
if (this.isConnecting && this.token === newToken) {
return;
}
this.isConnecting = true;
this.token = newToken;
console.log('Connecting to WebSocket server');
// Disconnect existing socket if necessary
if (this.socket) {
// Save listeners before disconnecting
const savedListeners = new Map(this.listeners);
// Disconnect and reset the socket
this.socket.disconnect();
this.socket = null;
// Restore listeners
this.listeners = savedListeners;
}
// Connect with auth token if available
this.socket = io(import.meta.env.VITE_BACKEND_URL, {
auth: this.token ? { token: this.token } : undefined,
});
this.socket.on('connect', () => {
console.log('Connected to WebSocket server');
console.log(
'Connected to WebSocket server with auth:',
this.token ? 'yes' : 'no'
);
this.isConnecting = false;
// Re-apply listeners to new socket connection
this.reapplyListeners();
});
this.socket.on('disconnect', () => {
console.log('Disconnected from WebSocket server');
this.isConnecting = false;
});
this.socket.on('connect_error', (error) => {
console.error('Connection error:', error);
this.isConnecting = false;
});
this.socket.on('app-error', (error) => {
console.error('Failed:', error);
console.error('Socket error:', error);
});
// Listen for incoming custom events
this.socket.onAny((event: string, data: any) => {
const eventListeners = this.listeners.get(event);
@@ -35,10 +79,16 @@ class SocketService {
});
}
// Re-apply all event listeners to the new socket connection
private reapplyListeners() {
// Nothing needed here as Socket.IO automatically handles event listeners
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
this.isConnecting = false;
}
}
@@ -46,41 +96,172 @@ class SocketService {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)?.push(listener);
const eventListeners = this.listeners.get(event);
// Avoid duplicate listeners
if (eventListeners && !eventListeners.includes(listener)) {
eventListeners.push(listener);
}
}
// Event abbestellen
// Unsubscribe from event
unsubscribeFromEvent<T>(event: string, listener: (data: T) => void) {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
this.listeners.set(
event,
eventListeners.filter((existingListener) => existingListener !== listener)
eventListeners.filter(
(existingListener) => existingListener !== listener
)
);
}
}
// Send chat message
sendMessage(
userName: string,
userAvatar: string,
message: string,
timestamp: string
) {
if (!this.socket || !this.socket.connected) {
this.connect();
// Nachricht senden
sendMessage(userName: string, userAvatar: string, message: string, timestamp: string) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('send-message', { userName, userAvatar, message, timestamp });
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
// Channel hinzufügen
addChannel(name: string, url: string, avatar: string, restream: boolean) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('add-channel', { name, url, avatar, restream });
this.socket.emit('send-message', {
userName,
userAvatar,
message,
timestamp,
});
}
// Aktuellen Channel setzen
// Add channel
addChannel(
name: string,
url: string,
avatar: string,
mode: ChannelMode,
headersJson: string,
) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson });
}
// Set current channel
setCurrentChannel(id: number) {
if (!this.socket) throw new Error('Socket is not connected.');
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('set-current-channel', id);
}
// Delete channel
deleteChannel(id: number) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('delete-channel', id);
}
// Update channel
updateChannel(id: number, updatedAttributes: any) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('update-channel', { id, updatedAttributes });
}
// Add playlist
addPlaylist(
playlist: string,
playlistName: string,
mode: ChannelMode,
playlistUpdate: boolean,
headers: string,
) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('add-playlist', {
playlist,
playlistName,
mode,
playlistUpdate,
headers,
});
}
// Update playlist
updatePlaylist(
playlist: string,
updatedAttributes: any,
) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('update-playlist', { playlist, updatedAttributes });
}
// Delete playlist
deletePlaylist(playlist: string) {
if (!this.socket || !this.socket.connected) {
this.connect();
if (!this.socket || !this.socket.connected) {
throw new Error('Socket is not connected.');
}
}
this.socket.emit('delete-playlist', playlist);
}
// Update authentication token and reconnect
updateAuthToken() {
// Force disconnect and reconnect with the new token
this.disconnect();
// Reset the token so connect() will use the new one from localStorage
this.token = null;
// Connect with the new token
this.connect();
}
}
const socketService = new SocketService();

View File

@@ -18,12 +18,19 @@ export interface RandomUser {
}[];
};
export type ChannelMode = 'direct' | 'proxy' | 'restream';
export interface Channel {
id: number;
name: string;
url: string;
avatar: string;
restream: boolean;
mode: ChannelMode;
headers: CustomHeader[];
group: string;
playlist: string;
playlistName: string;
playlistUpdate: boolean;
}
export interface ChatMessage {
@@ -32,3 +39,19 @@ export interface ChatMessage {
message: string;
timestamp: string;
}
export interface CustomHeader {
key: string;
value: string;
}
export type ToastType = 'info' | 'success' | 'error' | 'loading';
export interface ToastNotification {
id: string;
type: ToastType;
title: string;
message?: string;
duration: number;
}

View File

@@ -1,46 +0,0 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
server {
listen 80;
location / {
proxy_pass http://frontend:80;
}
location /api/ {
proxy_pass http://backend:5000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://backend:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /streams/ {
root /;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range';
add_header Cache-Control no-cache;
}
}
}