diff --git a/core/views.py b/core/views.py
index 3f7def1e..c41a8d23 100644
--- a/core/views.py
+++ b/core/views.py
@@ -2,13 +2,13 @@ import os
import sys
import subprocess
import logging
-
+import re
from django.conf import settings
from django.http import StreamingHttpResponse, HttpResponseServerError
-from django.db.models import F
+from django.db import transaction
from django.shortcuts import render
-
from apps.channels.models import Channel, Stream
+from apps.m3u.models import M3UAccountProfile
from core.models import StreamProfile
# Configure logging to output to the console.
@@ -41,50 +41,89 @@ def stream_view(request, stream_id):
stream = channel.streams.first()
logger.debug("Using stream: ID=%s, Name=%s", stream.id, stream.name)
+ # Retrieve m3u account to determine number of streams and profiles
+ m3u_account = stream.m3u_account
+ logger.debug(f"Using M3U account ID={m3u_account.id}, Name={m3u_account.name}")
+
# Use the custom URL if available; otherwise, use the standard URL.
input_url = stream.custom_url or stream.url
logger.debug("Input URL: %s", input_url)
+ # Determine which profile we can use
+ m3u_profiles = m3u_account.profiles.all()
+ default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
+
+ # Get the remaining objects
+ profiles = [obj for obj in m3u_profiles if not obj.is_default]
+
+ active_profile = None
+ for profile in [default_profile] + profiles:
+ if not profile.is_active:
+ continue
+ if profile.current_viewers < profile.max_streams:
+ logger.debug(f"Using M3U profile ID={profile.id}")
+ active_profile = M3UAccountProfile.objects.get(id=profile.id)
+ logger.debug("Executing the following pattern replacement:")
+ logger.debug(f" search: {profile.search_pattern}")
+ # Convert $1 to \1 for Python regex
+ safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
+ logger.debug(f" replace: {profile.replace_pattern}")
+ logger.debug(f" safe replace: {safe_replace_pattern}")
+ stream_url = re.sub(profile.search_pattern, safe_replace_pattern, input_url)
+ logger.debug(f"Generated stream url: {stream_url}")
+ break
+
+ if active_profile is None:
+ logger.exception("No available profiles for the stream")
+ return HttpResponseServerError("No available profiles for the stream")
+
# Get the stream profile set on the channel.
- # (Ensure your Channel model has a 'stream_profile' field.)
- profile = channel.stream_profile
- if not profile:
+ stream_profile = channel.stream_profile
+ if not stream_profile:
logger.error("No stream profile set for channel ID=%s", channel.id)
return HttpResponseServerError("No stream profile set for this channel.")
- logger.debug("Stream profile used: %s", profile.profile_name)
+ logger.debug("Stream profile used: %s", stream_profile.profile_name)
# Determine the user agent to use.
- user_agent = profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
+ user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0")
logger.debug("User agent: %s", user_agent)
# Substitute placeholders in the parameters template.
- parameters = profile.parameters.format(userAgent=user_agent, streamUrl=input_url)
+ parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url)
logger.debug("Formatted parameters: %s", parameters)
# Build the final command.
- cmd = [profile.command] + parameters.split()
+ cmd = [stream_profile.command] + parameters.split()
logger.debug("Executing command: %s", cmd)
- # Increment the viewer count.
- Stream.objects.filter(id=stream.id).update(current_viewers=F('current_viewers') + 1)
- logger.debug("Viewer count incremented for stream ID=%s", stream.id)
+ # Transactional block to ensure atomic viewer count updates.
+ with transaction.atomic():
+ # Increment the viewer count.
+ active_profile.current_viewers += 1
+ active_profile.save()
+ logger.debug("Viewer count incremented for stream ID=%s", stream.id)
- # Start the streaming process.
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ # Start the streaming process.
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ def stream_generator(proc, s):
+ try:
+ while True:
+ chunk = proc.stdout.read(8192)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ # Decrement the viewer count once streaming ends.
+ try:
+ with transaction.atomic():
+ active_profile.current_viewers -= 1
+ active_profile.save()
+ logger.debug("Viewer count decremented for stream ID=%s", s.id)
+ except Exception as e:
+ logger.error(f"Error updating viewer count for stream {s.id}: {e}")
+
+ return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")
except Exception as e:
logger.exception("Error starting stream for channel ID=%s", stream_id)
return HttpResponseServerError(f"Error starting stream: {e}")
-
- def stream_generator(proc, s):
- try:
- while True:
- chunk = proc.stdout.read(8192)
- if not chunk:
- break
- yield chunk
- finally:
- # Decrement the viewer count once streaming ends.
- Stream.objects.filter(id=s.id).update(current_viewers=F('current_viewers') - 1)
- logger.debug("Viewer count decremented for stream ID=%s", s.id)
-
- return StreamingHttpResponse(stream_generator(process, stream), content_type="video/MP2T")
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e0a8b867..1aa5b768 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -13,11 +13,14 @@
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
+ "@videojs/http-streaming": "^3.17.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"eslint": "^8.57.1",
"formik": "^2.4.6",
+ "hls.js": "^1.5.20",
"material-react-table": "^3.2.0",
+ "mpegts.js": "^1.8.0",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
@@ -25,6 +28,7 @@
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",
+ "video.js": "^8.21.0",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
@@ -4378,6 +4382,54 @@
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
"license": "ISC"
},
+ "node_modules/@videojs/http-streaming": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.0.tgz",
+ "integrity": "sha512-Ch1P3tvvIEezeZXyK11UfWgp4cWKX4vIhZ30baN/lRinqdbakZ5hiAI3pGjRy3d+q/Epyc8Csz5xMdKNNGYpcw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@videojs/vhs-utils": "^4.1.1",
+ "aes-decrypter": "^4.0.2",
+ "global": "^4.4.0",
+ "m3u8-parser": "^7.2.0",
+ "mpd-parser": "^1.3.1",
+ "mux.js": "7.1.0",
+ "video.js": "^7 || ^8"
+ },
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ },
+ "peerDependencies": {
+ "video.js": "^8.19.0"
+ }
+ },
+ "node_modules/@videojs/vhs-utils": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz",
+ "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "global": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ }
+ },
+ "node_modules/@videojs/xhr": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz",
+ "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "global": "~4.4.0",
+ "is-function": "^1.0.1"
+ }
+ },
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -4509,6 +4561,15 @@
"@xtuc/long": "4.2.2"
}
},
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.10",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
+ "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -4613,6 +4674,18 @@
"node": ">=8.9"
}
},
+ "node_modules/aes-decrypter": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz",
+ "integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@videojs/vhs-utils": "^4.1.1",
+ "global": "^4.4.0",
+ "pkcs7": "^1.0.4"
+ }
+ },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -6758,6 +6831,11 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
+ "node_modules/dom-walk": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
+ "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
+ },
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@@ -7123,6 +7201,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/es6-promise": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
+ "license": "MIT"
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -8598,6 +8682,16 @@
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="
},
+ "node_modules/global": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
+ "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
+ "license": "MIT",
+ "dependencies": {
+ "min-document": "^2.19.0",
+ "process": "^0.11.10"
+ }
+ },
"node_modules/global-modules": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
@@ -8818,6 +8912,12 @@
"npm": ">= 9"
}
},
+ "node_modules/hls.js": {
+ "version": "1.5.20",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
+ "integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -9400,6 +9500,12 @@
"node": ">=8"
}
},
+ "node_modules/is-function": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
+ "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
+ "license": "MIT"
+ },
"node_modules/is-generator-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
@@ -11033,6 +11139,17 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/m3u8-parser": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz",
+ "integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@videojs/vhs-utils": "^4.1.1",
+ "global": "^4.4.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -11276,6 +11393,14 @@
"node": ">=6"
}
},
+ "node_modules/min-document": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+ "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
+ "dependencies": {
+ "dom-walk": "^0.1.0"
+ }
+ },
"node_modules/mini-css-extract-plugin": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz",
@@ -11338,6 +11463,31 @@
"mkdirp": "bin/cmd.js"
}
},
+ "node_modules/mpd-parser": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.1.tgz",
+ "integrity": "sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@videojs/vhs-utils": "^4.0.0",
+ "@xmldom/xmldom": "^0.8.3",
+ "global": "^4.4.0"
+ },
+ "bin": {
+ "mpd-to-m3u8-json": "bin/parse.js"
+ }
+ },
+ "node_modules/mpegts.js": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/mpegts.js/-/mpegts.js-1.8.0.tgz",
+ "integrity": "sha512-ZtujqtmTjWgcDDkoOnLvrOKUTO/MKgLHM432zGDI8oPaJ0S+ebPxg1nEpDpLw6I7KmV/GZgUIrfbWi3qqEircg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "es6-promise": "^4.2.5",
+ "webworkify-webpack": "github:xqq/webworkify-webpack"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -11355,6 +11505,23 @@
"multicast-dns": "cli.js"
}
},
+ "node_modules/mux.js": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.1.0.tgz",
+ "integrity": "sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.11.2",
+ "global": "^4.4.0"
+ },
+ "bin": {
+ "muxjs-transmux": "bin/transmux.js"
+ },
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ }
+ },
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -11926,6 +12093,18 @@
"node": ">= 6"
}
},
+ "node_modules/pkcs7": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz",
+ "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5"
+ },
+ "bin": {
+ "pkcs7": "bin/cli.js"
+ }
+ },
"node_modules/pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -13316,6 +13495,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -16243,6 +16431,57 @@
"node": ">= 0.8"
}
},
+ "node_modules/video.js": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.21.0.tgz",
+ "integrity": "sha512-zcwerRb257QAuWfi8NH9yEX7vrGKFthjfcONmOQ4lxFRpDAbAi+u5LAjCjMWqhJda6zEmxkgdDpOMW3Y21QpXA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@videojs/http-streaming": "^3.16.2",
+ "@videojs/vhs-utils": "^4.1.1",
+ "@videojs/xhr": "2.7.0",
+ "aes-decrypter": "^4.0.2",
+ "global": "4.4.0",
+ "m3u8-parser": "^7.2.0",
+ "mpd-parser": "^1.3.1",
+ "mux.js": "^7.0.1",
+ "videojs-contrib-quality-levels": "4.1.0",
+ "videojs-font": "4.2.0",
+ "videojs-vtt.js": "0.15.5"
+ }
+ },
+ "node_modules/videojs-contrib-quality-levels": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz",
+ "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "global": "^4.4.0"
+ },
+ "engines": {
+ "node": ">=16",
+ "npm": ">=8"
+ },
+ "peerDependencies": {
+ "video.js": "^8"
+ }
+ },
+ "node_modules/videojs-font": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz",
+ "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/videojs-vtt.js": {
+ "version": "0.15.5",
+ "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz",
+ "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "global": "^4.3.1"
+ }
+ },
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
@@ -16533,6 +16772,11 @@
"node": ">=0.8.0"
}
},
+ "node_modules/webworkify-webpack": {
+ "version": "2.1.5",
+ "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef",
+ "license": "MIT"
+ },
"node_modules/whatwg-encoding": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index de62f131..9c7b6345 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,11 +8,14 @@
"@fontsource/roboto": "^5.1.1",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
+ "@videojs/http-streaming": "^3.17.0",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"eslint": "^8.57.1",
"formik": "^2.4.6",
+ "hls.js": "^1.5.20",
"material-react-table": "^3.2.0",
+ "mpegts.js": "^1.8.0",
"planby": "^1.1.7",
"prettier": "^3.5.2",
"react": "^19.0.0",
@@ -20,6 +23,7 @@
"react-router-dom": "^7.2.0",
"react-scripts": "5.0.1",
"react-window": "^1.8.11",
+ "video.js": "^8.21.0",
"web-vitals": "^2.1.4",
"yup": "^1.6.1",
"zustand": "^5.0.3"
@@ -49,5 +53,5 @@
"last 1 safari version"
]
},
- "proxy": "http://127.0.0.1:5656"
+ "proxy": "http://host.docker.internal:5656"
}
diff --git a/frontend/src/App.js b/frontend/src/App.js
index 0220e64b..06e8447a 100644
--- a/frontend/src/App.js
+++ b/frontend/src/App.js
@@ -30,7 +30,7 @@ import logo from './images/logo.png';
const drawerWidth = 240;
const miniDrawerWidth = 60;
-const defaultRoute = '/channels';
+const defaultRoute = '/m3u';
const App = () => {
const [open, setOpen] = useState(true);
@@ -116,7 +116,6 @@ const App = () => {
{
const epgs = useEPGsStore((state) => state.epgs);
@@ -36,15 +31,15 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
const formik = useFormik({
initialValues: {
- name: "",
- source_type: "",
- url: "",
- api_key: "",
+ name: '',
+ source_type: '',
+ url: '',
+ api_key: '',
is_active: true,
},
validationSchema: Yup.object({
- name: Yup.string().required("Name is required"),
- source_type: Yup.string().required("Source type is required"),
+ name: Yup.string().required('Name is required'),
+ source_type: Yup.string().required('Source type is required'),
}),
onSubmit: async (values, { setSubmitting, resetForm }) => {
if (epg?.id) {
@@ -85,8 +80,8 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
+ }
+ />
+
+ ))}
+
+
+
+
+
+ New
+
+
+
+
+
+ >
+ );
+};
+
+export default M3UProfiles;
diff --git a/frontend/src/components/tables/ChannelsTable.js b/frontend/src/components/tables/ChannelsTable.js
index 3610d361..e9df2030 100644
--- a/frontend/src/components/tables/ChannelsTable.js
+++ b/frontend/src/components/tables/ChannelsTable.js
@@ -22,6 +22,7 @@ import {
Edit as EditIcon,
Add as AddIcon,
SwapVert as SwapVertIcon,
+ LiveTv as LiveTvIcon,
} from '@mui/icons-material';
import API from '../../api';
import ChannelForm from '../forms/Channel';
@@ -57,13 +58,14 @@ const Example = () => {
},
{
header: 'Group',
+
accessorFn: (row) => row.channel_group?.name || '',
},
{
header: 'Logo',
accessorKey: 'logo_url',
- size: 50,
- cell: (info) => (
+ size: 55,
+ Cell: ({ cell }) => (
{
alignItems: 'center',
}}
>
-
+
),
meta: {
@@ -121,7 +123,14 @@ const Example = () => {
const selected = table
.getRowModel()
.rows.filter((row) => row.getIsSelected());
- await API.assignChannelNumbers(selected.map((sel) => sel.id));
+ await API.assignChannelNumbers(selected.map((sel) => sel.original.id));
+
+ // @TODO: update the channels that were assigned
+ };
+
+ const closeChannelForm = () => {
+ setChannel(null);
+ setChannelModalOpen(false);
};
useEffect(() => {
@@ -177,7 +186,6 @@ const Example = () => {
columns,
data: channels,
enablePagination: false,
- // enableRowNumbers: true,
enableRowVirtualization: true,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
@@ -196,23 +204,31 @@ const Example = () => {
renderRowActions: ({ row }) => (
{
editChannel(row.original);
}}
sx={{ p: 0 }}
>
- {/* Small icon size */}
+
deleteChannel(row.original.id)}
sx={{ p: 0 }}
>
- {/* Small icon size */}
+
+ {/* previewChannel(row.original.id)}
+ sx={{ p: 0 }}
+ >
+
+ */}
),
muiTableContainerProps: {
@@ -234,32 +250,32 @@ const Example = () => {
Channels
editChannel()}
>
- {/* Small icon size */}
+
- {/* Small icon size */}
+
- {/* Small icon size */}
+
@@ -288,7 +304,7 @@ const Example = () => {
setChannelModalOpen(false)}
+ onClose={closeChannelForm}
/>
{
setSnackbarOpen(true);
};
+ const closeEPGForm = () => {
+ setEPG(null);
+ setEPGModalOpen(false);
+ };
+
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@@ -182,11 +187,7 @@ const EPGsTable = () => {
>
- setEPGModalOpen(false)}
- />
+
{
const [sorting, setSorting] = useState([]);
const editPlaylist = async (playlist = null) => {
- setPlaylist(playlist);
+ if (playlist) {
+ setPlaylist(playlist);
+ }
setPlaylistModalOpen(true);
};
@@ -118,6 +116,11 @@ const Example = () => {
await API.deletePlaylist(id);
};
+ const closeModal = () => {
+ setPlaylistModalOpen(false);
+ setPlaylist(null);
+ };
+
const deletePlaylists = async (ids) => {
const selected = table
.getRowModel()
@@ -228,7 +231,7 @@ const Example = () => {
setPlaylistModalOpen(false)}
+ onClose={closeModal}
/>
);
diff --git a/frontend/src/components/tables/StreamProfilesTable.js b/frontend/src/components/tables/StreamProfilesTable.js
index a00256d1..b23257a8 100644
--- a/frontend/src/components/tables/StreamProfilesTable.js
+++ b/frontend/src/components/tables/StreamProfilesTable.js
@@ -116,6 +116,11 @@ const StreamProfiles = () => {
await API.deleteStreamProfile(ids);
};
+ const closeStreamProfileForm = () => {
+ setProfile(null);
+ setProfileModalOpen(false);
+ };
+
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@@ -210,7 +215,7 @@ const StreamProfiles = () => {
setProfileModalOpen(false)}
+ onClose={closeStreamProfileForm}
/>
);
diff --git a/frontend/src/components/tables/StreamsTable.js b/frontend/src/components/tables/StreamsTable.js
index dcfffdac..954432a8 100644
--- a/frontend/src/components/tables/StreamsTable.js
+++ b/frontend/src/components/tables/StreamsTable.js
@@ -60,7 +60,7 @@ const Example = () => {
const createChannelFromStream = async (stream) => {
await API.createChannelFromStream({
channel_name: stream.name,
- channel_number: 0,
+ channel_number: null,
stream_id: stream.id,
});
};
@@ -96,6 +96,11 @@ const Example = () => {
await API.deleteStreams(selected.map((stream) => stream.original.id));
};
+ const closeStreamForm = () => {
+ setStream(null);
+ setModalOpen(false);
+ };
+
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@@ -222,7 +227,7 @@ const Example = () => {
setModalOpen(false)}
+ onClose={closeStreamForm}
/>
);
diff --git a/frontend/src/components/tables/UserAgentsTable.js b/frontend/src/components/tables/UserAgentsTable.js
index d546a734..7271aff8 100644
--- a/frontend/src/components/tables/UserAgentsTable.js
+++ b/frontend/src/components/tables/UserAgentsTable.js
@@ -114,6 +114,11 @@ const UserAgentsTable = () => {
}
};
+ const closeUserAgentForm = () => {
+ setUserAgent(null);
+ setUserAgentModalOpen(false);
+ };
+
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@@ -210,7 +215,7 @@ const UserAgentsTable = () => {
setUserAgentModalOpen(false)}
+ onClose={closeUserAgentForm}
/>
>
);
diff --git a/frontend/src/pages/Guide.js b/frontend/src/pages/Guide.js
index df40e4bc..77955963 100644
--- a/frontend/src/pages/Guide.js
+++ b/frontend/src/pages/Guide.js
@@ -60,26 +60,18 @@ const TVChannelGuide = ({ startDate, endDate }) => {
useEffect(() => {
const fetchPrograms = async () => {
- console.log('Fetching program grid...');
const programs = await API.getGrid();
- console.log(`Received ${programs.length} programs`);
const programIds = [...new Set(programs.map((prog) => prog.tvg_id))];
- console.log('program tvg-ids');
- console.log(programIds);
const filteredChannels = channels.filter((ch) =>
programIds.includes(ch.tvg_id)
);
- console.log(
- `found ${filteredChannels.length} channels with matching tvg-ids`
- );
- console.log(filteredChannels);
setGuideChannels(filteredChannels);
setActiveChannels(guideChannels.map((channel) => channel.channel_name));
setPrograms(programs);
};
fetchPrograms();
- }, [channels]);
+ }, [channels, activeChannels]);
const latestHalfHour = new Date();
diff --git a/frontend/src/store/playlists.js b/frontend/src/store/playlists.js
index 06930b73..274c943a 100644
--- a/frontend/src/store/playlists.js
+++ b/frontend/src/store/playlists.js
@@ -1,8 +1,9 @@
-import { create } from "zustand";
-import api from "../api";
+import { create } from 'zustand';
+import api from '../api';
const usePlaylistsStore = create((set) => ({
playlists: [],
+ profiles: {},
isLoading: false,
error: null,
@@ -10,30 +11,54 @@ const usePlaylistsStore = create((set) => ({
set({ isLoading: true, error: null });
try {
const playlists = await api.getPlaylists();
- set({ playlists: playlists, isLoading: false });
+ set({
+ playlists: playlists,
+ isLoading: false,
+ profiles: playlists.reduce((acc, playlist) => {
+ acc[playlist.id] = playlist.profiles;
+ return acc;
+ }, {}),
+ });
} catch (error) {
- console.error("Failed to fetch playlists:", error);
- set({ error: "Failed to load playlists.", isLoading: false });
+ console.error('Failed to fetch playlists:', error);
+ set({ error: 'Failed to load playlists.', isLoading: false });
}
},
addPlaylist: (newPlaylist) =>
set((state) => ({
playlists: [...state.playlists, newPlaylist],
+ profiles: {
+ ...state.profiles,
+ [newPlaylist.id]: newPlaylist.profiles,
+ },
})),
updatePlaylist: (playlist) =>
set((state) => ({
playlists: state.playlists.map((pl) =>
- pl.id === playlist.id ? playlist : pl,
+ pl.id === playlist.id ? playlist : pl
),
+ profiles: {
+ ...state.profiles,
+ [playlist.id]: playlist.profiles,
+ },
+ })),
+
+ updateProfiles: (playlistId, profiles) =>
+ set((state) => ({
+ profiles: {
+ ...state.profiles,
+ [playlistId]: profiles,
+ },
})),
removePlaylists: (playlistIds) =>
set((state) => ({
playlists: state.playlists.filter(
- (playlist) => !playlistIds.includes(playlist.id),
+ (playlist) => !playlistIds.includes(playlist.id)
),
+ // @TODO: remove playlist profiles here
})),
}));