Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9705451ce | ||
|
|
c4146de1e2 | ||
|
|
4a55c4575e | ||
|
|
62a3b506b5 | ||
|
|
3e2259eaef | ||
|
|
40bdcba29f | ||
|
|
7d032c2703 | ||
|
|
928a4bf52a | ||
|
|
e226dd507f | ||
|
|
8b2cd8a1c9 | ||
|
|
4fe017a15c | ||
|
|
6a40e4fd7d | ||
|
|
b5c0769654 | ||
|
|
f0ab4f70ba | ||
|
|
170de2da33 | ||
|
|
ee16181219 | ||
|
|
61646cf3fc | ||
|
|
a5670cd1b2 | ||
|
|
e752c36c2d | ||
|
|
af7f02d38a | ||
|
|
c7aa8c0c80 | ||
|
|
01750b3137 | ||
|
|
8d0032ad59 | ||
|
|
b3e3870c89 | ||
|
|
2962da2f9a | ||
|
|
c2d181d833 | ||
|
|
fb1abd294e | ||
|
|
f19c674409 | ||
|
|
2909a15528 | ||
|
|
11dedc6614 | ||
|
|
3787a7730a | ||
|
|
27a4b216ec | ||
|
|
e1d2ae8d7c | ||
|
|
42de5e7aa3 | ||
|
|
7ab022b4ab | ||
|
|
6547095095 | ||
|
|
7a1740e8bc | ||
|
|
ac78f53cdd | ||
|
|
d2d03bb972 | ||
|
|
399b51e93c | ||
|
|
80795dc5dc | ||
|
|
bc738cc12d | ||
|
|
a8856ace0c | ||
|
|
bac5999cfd | ||
|
|
0a1f434d7a | ||
|
|
be044271e4 | ||
|
|
5dcb1050e7 | ||
|
|
8d9c1f41eb | ||
|
|
320b716664 | ||
|
|
540914f411 | ||
|
|
00bb05c71b | ||
|
|
ca7c1dcdb5 | ||
|
|
a5d42685ae | ||
|
|
32e926b978 | ||
|
|
483412f50e | ||
|
|
b1d53f0051 | ||
|
|
b65b5f1a8f | ||
|
|
ee247bc5b5 | ||
|
|
669d08d6c6 | ||
|
|
456f97e1c5 | ||
|
|
96e4ab927b | ||
|
|
53f01f4ddf | ||
|
|
383beac9c2 | ||
|
|
11df48fa3d | ||
|
|
9fb3ba1768 | ||
|
|
8a919768f8 | ||
|
|
ddd9a797a2 | ||
|
|
34e195e2bb | ||
|
|
a13c04603d | ||
|
|
2c92d75b03 | ||
|
|
0d564ffe59 | ||
|
|
355c44241c | ||
|
|
5772c85d53 | ||
|
|
3c7cf42a8d | ||
|
|
36a7ebd626 | ||
|
|
624586ef62 | ||
|
|
a3d36493ec | ||
|
|
8fbbf73ff7 | ||
|
|
68060097c5 | ||
|
|
d79e90015f | ||
|
|
116cabcb55 | ||
|
|
588c09646b | ||
|
|
780c85d052 | ||
|
|
d19d0dda41 | ||
|
|
d24c671892 | ||
|
|
b0a3f5a3a1 | ||
|
|
78ce8f8620 | ||
|
|
deebc1f509 | ||
|
|
de783d3fc1 | ||
|
|
5820a8b40a | ||
|
|
3932b0cc33 | ||
|
|
06b04c57b6 | ||
|
|
8fb36274df | ||
|
|
2752747121 | ||
|
|
2677924c0e | ||
|
|
500455c093 | ||
|
|
953f54657c | ||
|
|
460ef35b62 | ||
|
|
7fc0f3f6bf | ||
|
|
65711df828 | ||
|
|
a6e6927fdd | ||
|
|
e9ea6ca16f | ||
|
|
f4bb7bc85e | ||
|
|
59e93fb629 | ||
|
|
f2f86fe88d | ||
|
|
ac0422ef94 | ||
|
|
c3bcfb4378 | ||
|
|
4854f767a5 | ||
|
|
672890974d | ||
|
|
edb6c53a0f | ||
|
|
fbffea81e8 | ||
|
|
127728f2c5 | ||
|
|
36181e2bfe | ||
|
|
6ada26a80b | ||
|
|
3799d7e23c | ||
|
|
0568f00cee | ||
|
|
01ab92606d | ||
|
|
7f4bfd94c1 | ||
|
|
edcbbd8789 | ||
|
|
b151a406b3 | ||
|
|
7adc220ce3 | ||
|
|
e69e55911c | ||
|
|
1f03c298ef | ||
|
|
070fb5d48a | ||
|
|
84776cf744 | ||
|
|
9fba7a49f1 | ||
|
|
3701d5bd43 | ||
|
|
2ce5fd2ca4 | ||
|
|
4efa722c4b | ||
|
|
a836c64871 | ||
|
|
f727477d47 | ||
|
|
8973d9bd5d | ||
|
|
416755d2d6 | ||
|
|
7eb8051021 | ||
|
|
90f770142e | ||
|
|
712c789681 | ||
|
|
e68565f878 | ||
|
|
8185d8b54e | ||
|
|
c05d94189f | ||
|
|
d3e496628a | ||
|
|
55dd8efbb2 | ||
|
|
77a0089e62 | ||
|
|
121aff4c1e | ||
|
|
20f3a4a5b5 | ||
|
|
b51d066792 | ||
|
|
f931e93355 | ||
|
|
65375b585a | ||
|
|
cd9a960c37 | ||
|
|
ee4786b991 | ||
|
|
61f36b5782 | ||
|
|
57c6f6eb80 | ||
|
|
fe3108013c | ||
|
|
30e6caa040 | ||
|
|
b3a82b17b1 | ||
|
|
d28b40d126 | ||
|
|
14e9519d29 | ||
|
|
d99adb25a2 | ||
|
|
49dc84463b | ||
|
|
41af2a019a | ||
|
|
bd42f1374d | ||
|
|
33453a3bf7 | ||
|
|
7031aad92a | ||
|
|
b1f0b78214 | ||
|
|
eff26a211b | ||
|
|
ac2b9fcee0 | ||
|
|
abb17f3ee2 | ||
|
|
86a09a90b3 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: antebrl
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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
29
.github/ISSUE_TEMPLATE/help_wanted.md
vendored
Normal 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
17
.github/ISSUE_TEMPLATE/question.md
vendored
Normal 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
44
.github/workflows/release-build.yml
vendored
Normal 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
9
.gitignore
vendored
@@ -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
32
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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
115
README.md
@@ -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
|
||||

|
||||

|
||||
|
||||
## ⚙️ 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
71
backend/controllers/AuthController.js
Normal file
71
backend/controllers/AuthController.js
Normal 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();
|
||||
},
|
||||
};
|
||||
149
backend/controllers/CentralChannelController.js
Normal file
149
backend/controllers/CentralChannelController.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
125
backend/controllers/ProxyController.js
Normal file
125
backend/controllers/ProxyController.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
backend/models/Playlist.js
Normal file
16
backend/models/Playlist.js
Normal 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;
|
||||
596
backend/package-lock.json
generated
596
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
getFilteredChannels({ playlistName, group }) {
|
||||
let filtered = this.channels;
|
||||
if (playlistName) {
|
||||
filtered = filtered.filter(ch => ch.playlistName && ch.playlistName.toLowerCase() == playlistName.toLowerCase());
|
||||
}
|
||||
const newChannel = new Channel(name, url, avatar, restream);
|
||||
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();
|
||||
|
||||
65
backend/services/ChannelStorage.js
Normal file
65
backend/services/ChannelStorage.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
97
backend/services/PlaylistService.js
Normal file
97
backend/services/PlaylistService.js
Normal 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();
|
||||
74
backend/services/PlaylistUpdater.js
Normal file
74
backend/services/PlaylistUpdater.js
Normal 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();
|
||||
82
backend/services/auth/AuthService.js
Normal file
82
backend/services/auth/AuthService.js
Normal 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();
|
||||
74
backend/services/proxy/ProxyHelperService.js
Normal file
74
backend/services/proxy/ProxyHelperService.js
Normal 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();
|
||||
85
backend/services/restream/FFmpegService.js
Normal file
85
backend/services/restream/FFmpegService.js
Normal 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
|
||||
};
|
||||
17
backend/services/restream/StorageService.js
Normal file
17
backend/services/restream/StorageService.js
Normal 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
|
||||
};
|
||||
32
backend/services/restream/StreamController.js
Normal file
32
backend/services/restream/StreamController.js
Normal 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
|
||||
};
|
||||
14
backend/services/session/SessionFactory.js
Normal file
14
backend/services/session/SessionFactory.js
Normal 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;
|
||||
22
backend/services/session/SessionHandler.js
Normal file
22
backend/services/session/SessionHandler.js
Normal 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;
|
||||
100
backend/services/session/StreamedSuSession.js
Normal file
100
backend/services/session/StreamedSuSession.js
Normal 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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -1,24 +1,86 @@
|
||||
const ChannelService = require('../services/ChannelService');
|
||||
const ChannelService = require("../services/ChannelService");
|
||||
const authService = require("../services/auth/AuthService");
|
||||
|
||||
module.exports = (io, socket) => {
|
||||
// Check if admin mode is required for channel modifications
|
||||
socket.on("add-channel", ({ name, url, avatar, mode, headersJson }) => {
|
||||
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 channels",
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('add-channel', ({ name, url, avatar, restream }) => {
|
||||
try {
|
||||
const newChannel = ChannelService.addChannel(name, url, avatar, restream);
|
||||
io.emit('channel-added', newChannel); // Broadcast to all clients
|
||||
} catch (err) {
|
||||
socket.emit('app-error', { message: err.message });
|
||||
}
|
||||
});
|
||||
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.on("set-current-channel", async (id) => {
|
||||
try {
|
||||
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.on('set-current-channel', (id) => {
|
||||
try {
|
||||
const nextChannel = 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.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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
124
backend/socket/PlaylistSocketHandler.js
Normal file
124
backend/socket/PlaylistSocketHandler.js
Normal 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)
|
||||
);
|
||||
};
|
||||
23
backend/socket/middleware/jwt.js
Normal file
23
backend/socket/middleware/jwt.js
Normal 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
39
deployment/README.md
Normal 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
|
||||
70
deployment/docker-compose.yml
Normal file
70
deployment/docker-compose.yml
Normal 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
|
||||
42
deployment/ghcr-docker-compose.yml
Normal file
42
deployment/ghcr-docker-compose.yml
Normal 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
|
||||
3
deployment/nginx/Dockerfile
Normal file
3
deployment/nginx/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
78
deployment/nginx/nginx.conf
Normal file
78
deployment/nginx/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
29
frontend/package-lock.json
generated
29
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
@@ -32,4 +33,4 @@
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 2.0 MiB |
@@ -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" />
|
||||
<Settings 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-2">
|
||||
<Tv2 className="w-5 h-5 text-blue-500" />
|
||||
<h2 className="text-xl font-semibold">Live Channels</h2>
|
||||
<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 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
|
||||
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={() => setIsModalOpen(true)}
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
51
frontend/src/components/SettingsModal.tsx
Normal file
51
frontend/src/components/SettingsModal.tsx
Normal 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;
|
||||
34
frontend/src/components/Tooltip.tsx
Normal file
34
frontend/src/components/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
93
frontend/src/components/TvPlaylistModal.tsx
Normal file
93
frontend/src/components/TvPlaylistModal.tsx
Normal 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;
|
||||
@@ -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: (
|
||||
@@ -46,65 +50,109 @@ 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(() => {
|
||||
const now = new Date().getTime();
|
||||
|
||||
const fragments = hls.levels[0]?.details?.fragments;
|
||||
const lastFragment = fragments?.[fragments.length - 1];
|
||||
if (!lastFragment || !lastFragment.programDateTime) return;
|
||||
if (channel.mode === 'restream') {
|
||||
const now = new Date().getTime();
|
||||
|
||||
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;
|
||||
|
||||
// It takes some time for the stream to load and play. Estimated here: 1s
|
||||
const timeTolerance = tolerance + 1;
|
||||
|
||||
if (videoLength + timeDiff + timeTolerance >= targetDelay) {
|
||||
hls.startLoad();
|
||||
video.play();
|
||||
clearInterval(interval);
|
||||
const fragments = hls.levels[0]?.details?.fragments;
|
||||
const lastFragment = fragments?.[fragments.length - 1];
|
||||
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) => acc + fragment.duration, 0);
|
||||
const targetDelay : number = Number(import.meta.env.VITE_STREAM_DELAY);
|
||||
|
||||
//Load stream if it is close to the target delay
|
||||
const timeTolerance = tolerance + 1;
|
||||
|
||||
const delay : number = videoLength + timeDiff + timeTolerance;
|
||||
if (delay >= targetDelay) {
|
||||
hls.startLoad();
|
||||
video.play();
|
||||
console.log("Starting stream");
|
||||
if (!toastDurationSet && toastStartId) {
|
||||
removeToast(toastStartId);
|
||||
}
|
||||
}, 1000);
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
510
frontend/src/components/add_channel/ChannelModal.tsx
Normal file
510
frontend/src/components/add_channel/ChannelModal.tsx
Normal 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;
|
||||
30
frontend/src/components/add_channel/CustomHeaderInput.tsx
Normal file
30
frontend/src/components/add_channel/CustomHeaderInput.tsx
Normal 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;
|
||||
99
frontend/src/components/admin/AdminContext.tsx
Normal file
99
frontend/src/components/admin/AdminContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
frontend/src/components/admin/AdminModal.tsx
Normal file
130
frontend/src/components/admin/AdminModal.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
81
frontend/src/components/notifications/ToastContainer.tsx
Normal file
81
frontend/src/components/notifications/ToastContainer.tsx
Normal 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;
|
||||
63
frontend/src/components/notifications/ToastContext.tsx
Normal file
63
frontend/src/components/notifications/ToastContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,5 +3,5 @@ import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<App />,
|
||||
<App />
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -38,4 +45,4 @@ const apiService = {
|
||||
},
|
||||
};
|
||||
|
||||
export default apiService;
|
||||
export default apiService;
|
||||
@@ -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.');
|
||||
if (!this.socket || !this.socket.connected) {
|
||||
throw new Error('Socket is not connected.');
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.emit('send-message', { userName, userAvatar, message, timestamp });
|
||||
this.socket.emit('send-message', {
|
||||
userName,
|
||||
userAvatar,
|
||||
message,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Channel hinzufügen
|
||||
addChannel(name: string, url: string, avatar: string, restream: boolean) {
|
||||
if (!this.socket) throw new Error('Socket is not connected.');
|
||||
// Add channel
|
||||
addChannel(
|
||||
name: string,
|
||||
url: string,
|
||||
avatar: string,
|
||||
mode: ChannelMode,
|
||||
headersJson: string,
|
||||
) {
|
||||
if (!this.socket || !this.socket.connected) {
|
||||
this.connect();
|
||||
|
||||
this.socket.emit('add-channel', { name, url, avatar, restream });
|
||||
if (!this.socket || !this.socket.connected) {
|
||||
throw new Error('Socket is not connected.');
|
||||
}
|
||||
}
|
||||
|
||||
this.socket.emit('add-channel', { name, url, avatar, mode, headersJson });
|
||||
}
|
||||
|
||||
// Aktuellen Channel setzen
|
||||
// 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();
|
||||
|
||||
@@ -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 {
|
||||
@@ -31,4 +38,20 @@ export interface ChatMessage {
|
||||
user: User;
|
||||
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;
|
||||
}
|
||||
46
nginx.conf
46
nginx.conf
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user