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 }) => { EPG Source @@ -169,7 +164,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => { disabled={formik.isSubmitting} size="small" > - {formik.isSubmitting ? : "Submit"} + {formik.isSubmitting ? : 'Submit'} diff --git a/frontend/src/components/forms/M3U.js b/frontend/src/components/forms/M3U.js index db8bcb9b..f18e22cd 100644 --- a/frontend/src/components/forms/M3U.js +++ b/frontend/src/components/forms/M3U.js @@ -1,5 +1,5 @@ // Modal.js -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect } from 'react'; import { Box, Typography, @@ -17,15 +17,17 @@ import { DialogTitle, DialogContent, DialogActions, -} from "@mui/material"; -import { useFormik } from "formik"; -import * as Yup from "yup"; -import API from "../../api"; -import useUserAgentsStore from "../../store/userAgents"; +} from '@mui/material'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import API from '../../api'; +import useUserAgentsStore from '../../store/userAgents'; +import M3UProfiles from './M3UProfiles'; const M3U = ({ playlist = null, isOpen, onClose }) => { const userAgents = useUserAgentsStore((state) => state.userAgents); const [file, setFile] = useState(null); + const [profileModalOpen, setProfileModalOpen] = useState(false); const handleFileChange = (e) => { const file = e.target.files[0]; @@ -36,15 +38,15 @@ const M3U = ({ playlist = null, isOpen, onClose }) => { const formik = useFormik({ initialValues: { - name: "", - server_url: "", + name: '', + server_url: '', max_streams: 0, - user_agent: "", + user_agent: '', is_active: true, }, validationSchema: Yup.object({ - name: Yup.string().required("Name is required"), - user_agent: Yup.string().required("User-Agent is required"), + name: Yup.string().required('Name is required'), + user_agent: Yup.string().required('User-Agent is required'), }), onSubmit: async (values, { setSubmitting, resetForm }) => { if (playlist?.id) { @@ -89,8 +91,8 @@ const M3U = ({ playlist = null, isOpen, onClose }) => { M3U Account @@ -131,7 +133,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => { direction="row" spacing={2} sx={{ - alignItems: "center", + alignItems: 'center', pt: 2, }} > @@ -143,7 +145,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => { name="uploaded_file" accept="image/*" onChange={(event) => handleFileChange(event)} - style={{ display: "none" }} + style={{ display: 'none' }} /> ); diff --git a/frontend/src/components/forms/M3UProfile.js b/frontend/src/components/forms/M3UProfile.js new file mode 100644 index 00000000..43395719 --- /dev/null +++ b/frontend/src/components/forms/M3UProfile.js @@ -0,0 +1,197 @@ +import React, { useState, useEffect } from 'react'; +import { + TextField, + Typography, + Card, + CardContent, + Dialog, + DialogContent, + DialogTitle, + DialogActions, + Button, + CircularProgress, +} from '@mui/material'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import API from '../../api'; + +const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { + const [searchPattern, setSearchPattern] = useState(''); + const [replacePattern, setReplacePattern] = useState(''); + + console.log(profile); + + let regex; + try { + regex = new RegExp(searchPattern, 'g'); + } catch (e) { + regex = null; + } + + const highlightedUrl = regex + ? m3u.server_url.replace(regex, (match) => `${match}`) + : m3u.server_url; + + const resultUrl = regex + ? m3u.server_url.replace(regex, replacePattern) + : m3u.server_url; + + const onSearchPatternUpdate = (e) => { + formik.handleChange(e); + setSearchPattern(e.target.value); + }; + + const onReplacePatternUpdate = (e) => { + formik.handleChange(e); + setReplacePattern(e.target.value); + }; + + const formik = useFormik({ + initialValues: { + name: '', + max_streams: 0, + search_pattern: '', + replace_pattern: '', + }, + validationSchema: Yup.object({ + name: Yup.string().required('Name is required'), + search_pattern: Yup.string().required('Search pattern is required'), + replace_pattern: Yup.string().required('Replace pattern is required'), + }), + onSubmit: async (values, { setSubmitting, resetForm }) => { + console.log('submiting'); + if (profile?.id) { + await API.updateM3UProfile(m3u.id, { + id: profile.id, + ...values, + }); + } else { + await API.addM3UProfile(m3u.id, values); + } + + resetForm(); + setSubmitting(false); + onClose(); + }, + }); + + useEffect(() => { + if (profile) { + setSearchPattern(profile.search_pattern); + setReplacePattern(profile.replace_pattern); + formik.setValues({ + name: profile.name, + max_streams: profile.max_streams, + search_pattern: profile.search_pattern, + replace_pattern: profile.replace_pattern, + }); + } else { + formik.resetForm(); + } + }, [profile]); + + return ( + + + M3U Profile + +
+ + + + + + + + + + +
+ + + + Search + + + + + + + Replace + {resultUrl} + + +
+ ); +}; + +export default RegexFormAndView; diff --git a/frontend/src/components/forms/M3UProfiles.js b/frontend/src/components/forms/M3UProfiles.js new file mode 100644 index 00000000..24694811 --- /dev/null +++ b/frontend/src/components/forms/M3UProfiles.js @@ -0,0 +1,133 @@ +import React, { useState, useMemo } from 'react'; +import { + Typography, + Dialog, + DialogContent, + DialogTitle, + DialogActions, + Button, + Box, + Switch, + IconButton, + List, + ListItem, + ListItemText, +} from '@mui/material'; +import API from '../../api'; +import M3UProfile from './M3UProfile'; +import { Delete as DeleteIcon, Edit as EditIcon } from '@mui/icons-material'; +import usePlaylistsStore from '../../store/playlists'; + +const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { + const profiles = usePlaylistsStore((state) => state.profiles[playlist.id]); + const [profileEditorOpen, setProfileEditorOpen] = useState(false); + const [profile, setProfile] = useState(null); + + const editProfile = (profile = null) => { + if (profile) { + setProfile(profile); + } + + setProfileEditorOpen(true); + }; + + const deleteProfile = async (id) => { + await API.deleteM3UProfile(playlist.id, id); + }; + + const toggleActive = async (values) => { + await API.updateM3UProfile(playlist.id, { + ...values, + is_active: !values.is_active, + }); + }; + + const closeEditor = () => { + setProfile(null); + setProfileEditorOpen(false); + }; + + if (!isOpen || !profiles) { + return <>; + } + + return ( + <> + + + Profiles + + + + {profiles + .filter((playlist) => playlist.is_default == false) + .map((item) => ( + + + + Max Streams: {item.max_streams} + + toggleActive(item)} + color="primary" + inputProps={{ 'aria-label': 'active switch' }} + /> + editProfile(item)} + color="warning" + > + + + deleteProfile(item.id)} + color="error" + > + + +
+ } + /> + + ))} + + + + + + + + + + + ); +}; + +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 })), }));