m3u profiles, style changes, etc

This commit is contained in:
kappa118 2025-02-28 14:33:54 -05:00
parent 57432748a4
commit 6ae80f4368
17 changed files with 874 additions and 123 deletions

View file

@ -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")

View file

@ -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",

View file

@ -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"
}

View file

@ -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 = () => {
<Box
sx={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
ml: `${open ? drawerWidth : miniDrawerWidth}px`,

View file

@ -360,6 +360,18 @@ export default class API {
useUserAgentsStore.getState().removeUserAgents([id]);
}
static async getPlaylist(id) {
const response = await fetch(`${host}/api/m3u/accounts/${id}/`, {
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
});
const retval = await response.json();
return retval;
}
static async getPlaylists() {
const response = await fetch(`${host}/api/m3u/accounts/`, {
headers: {
@ -603,4 +615,63 @@ export default class API {
const retval = await response.json();
return retval.data;
}
static async addM3UProfile(accountId, values) {
const response = await fetch(
`${host}/api/m3u/accounts/${accountId}/profiles/`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
}
);
const retval = await response.json();
if (retval.id) {
// Fetch m3u account to update it with its new playlists
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore
.getState()
.updateProfiles(playlist.id, playlist.profiles);
}
return retval;
}
static async deleteM3UProfile(accountId, id) {
const response = await fetch(
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
}
);
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore.getState().updatePlaylist(playlist);
}
static async updateM3UProfile(accountId, values) {
const { id, ...payload } = values;
const response = await fetch(
`${host}/api/m3u/accounts/${accountId}/profiles/${id}/`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${await getAuthToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore.getState().updateProfiles(playlist.id, playlist.profiles);
}
}

View file

@ -1,15 +1,10 @@
// Modal.js
import React, { useState, useEffect } from "react";
import React, { useState, useEffect } from 'react';
import {
Box,
Modal,
Typography,
Stack,
TextField,
Button,
Select,
MenuItem,
Grid2,
InputLabel,
FormControl,
CircularProgress,
@ -17,11 +12,11 @@ import {
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useFormik } from "formik";
import * as Yup from "yup";
import API from "../../api";
import useEPGsStore from "../../store/epgs";
} from '@mui/material';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import API from '../../api';
import useEPGsStore from '../../store/epgs';
const EPG = ({ epg = null, isOpen, onClose }) => {
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 }) => {
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
EPG Source
@ -169,7 +164,7 @@ const EPG = ({ epg = null, isOpen, onClose }) => {
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>

View file

@ -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 }) => {
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
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' }}
/>
<label htmlFor="uploaded_file">
<Button variant="contained" component="span">
@ -198,7 +200,7 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
name="is_active"
checked={formik.values.is_active}
onChange={(e) =>
formik.setFieldValue("is_active", e.target.checked)
formik.setFieldValue('is_active', e.target.checked)
}
/>
}
@ -207,18 +209,33 @@ const M3U = ({ playlist = null, isOpen, onClose }) => {
</DialogContent>
<DialogActions>
{/* Submit button */}
<Button
variant="contained"
color="primary"
size="small"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
fullWidth
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : "Submit"}
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
{playlist && (
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
)}
</form>
</Dialog>
);

View file

@ -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) => `<mark>${match}</mark>`)
: 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 (
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{ backgroundColor: 'primary.main', color: 'primary.contrastText' }}
>
M3U Profile
</DialogTitle>
<form onSubmit={formik.handleSubmit}>
<DialogContent>
<TextField
fullWidth
id="name"
name="name"
label="Name"
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
variant="standard"
/>
<TextField
fullWidth
id="max_streams"
name="max_streams"
label="Max Streams"
value={formik.values.max_streams}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
error={
formik.touched.max_streams && Boolean(formik.errors.max_streams)
}
helperText={formik.touched.max_streams && formik.errors.max_streams}
variant="standard"
/>
<TextField
fullWidth
id="search_pattern"
name="search_pattern"
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.search_pattern &&
Boolean(formik.errors.search_pattern)
}
helperText={
formik.touched.search_pattern && formik.errors.search_pattern
}
variant="standard"
/>
<TextField
fullWidth
id="replace_pattern"
name="replace_pattern"
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
onBlur={formik.handleBlur}
error={
formik.touched.replace_pattern &&
Boolean(formik.errors.replace_pattern)
}
helperText={
formik.touched.replace_pattern && formik.errors.replace_pattern
}
variant="standard"
/>
</DialogContent>
<DialogActions>
<Button
type="submit"
variant="contained"
color="primary"
disabled={formik.isSubmitting}
size="small"
>
{formik.isSubmitting ? <CircularProgress size={24} /> : 'Submit'}
</Button>
</DialogActions>
</form>
<Card>
<CardContent>
<Typography variant="h6">Search</Typography>
<Typography
dangerouslySetInnerHTML={{ __html: highlightedUrl }}
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
/>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6">Replace</Typography>
<Typography>{resultUrl}</Typography>
</CardContent>
</Card>
</Dialog>
);
};
export default RegexFormAndView;

View file

@ -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 (
<>
<Dialog open={isOpen} onClose={onClose}>
<DialogTitle
sx={{
backgroundColor: 'primary.main',
color: 'primary.contrastText',
}}
>
Profiles
</DialogTitle>
<DialogContent>
<List>
{profiles
.filter((playlist) => playlist.is_default == false)
.map((item) => (
<ListItem
key={item.id}
sx={{
display: 'flex',
alignItems: 'center',
marginBottom: 2,
}}
>
<ListItemText
primary={item.name}
secondary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ marginRight: 2 }}>
Max Streams: {item.max_streams}
</Typography>
<Switch
checked={item.is_active}
onChange={() => toggleActive(item)}
color="primary"
inputProps={{ 'aria-label': 'active switch' }}
/>
<IconButton
onClick={() => editProfile(item)}
color="warning"
>
<EditIcon />
</IconButton>
<IconButton
onClick={() => deleteProfile(item.id)}
color="error"
>
<DeleteIcon />
</IconButton>
</Box>
}
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button
variant="contained"
color="primary"
size="small"
onClick={editProfile}
>
New
</Button>
</DialogActions>
</Dialog>
<M3UProfile
m3u={playlist}
profile={profile}
isOpen={profileEditorOpen}
onClose={closeEditor}
/>
</>
);
};
export default M3UProfiles;

View file

@ -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 }) => (
<Grid2
container
direction="row"
@ -72,7 +74,7 @@ const Example = () => {
alignItems: 'center',
}}
>
<img src={info.getValue() || logo} width="20" />
<img src={cell.getValue() || logo} width="20" />
</Grid2>
),
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 }) => (
<Box sx={{ justifyContent: 'right' }}>
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
size="small"
color="warning"
onClick={() => {
editChannel(row.original);
}}
sx={{ p: 0 }}
>
<EditIcon fontSize="small" /> {/* Small icon size */}
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
onClick={() => deleteChannel(row.original.id)}
sx={{ p: 0 }}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<DeleteIcon fontSize="small" />
</IconButton>
{/* <IconButton
size="small"
color="error"
onClick={() => previewChannel(row.original.id)}
sx={{ p: 0 }}
>
<LiveTvIcon fontSize="small" />
</IconButton> */}
</Box>
),
muiTableContainerProps: {
@ -234,32 +250,32 @@ const Example = () => {
<Typography>Channels</Typography>
<Tooltip title="Add New Channel">
<IconButton
size="small" // Makes the button smaller
color="success" // Red color for delete actions
size="small"
color="success"
variant="contained"
onClick={() => editChannel()}
>
<AddIcon fontSize="small" /> {/* Small icon size */}
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete Channels">
<IconButton
size="small" // Makes the button smaller
color="error" // Red color for delete actions
size="small"
color="error"
variant="contained"
onClick={deleteChannels}
>
<DeleteIcon fontSize="small" /> {/* Small icon size */}
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Assign Channels">
<IconButton
size="small" // Makes the button smaller
color="warning" // Red color for delete actions
size="small"
color="warning"
variant="contained"
onClick={assignChannels}
>
<SwapVertIcon fontSize="small" /> {/* Small icon size */}
<SwapVertIcon fontSize="small" />
</IconButton>
</Tooltip>
@ -288,7 +304,7 @@ const Example = () => {
<ChannelForm
channel={channel}
isOpen={channelModelOpen}
onClose={() => setChannelModalOpen(false)}
onClose={closeChannelForm}
/>
<Popover

View file

@ -84,6 +84,11 @@ const EPGsTable = () => {
setSnackbarOpen(true);
};
const closeEPGForm = () => {
setEPG(null);
setEPGModalOpen(false);
};
useEffect(() => {
if (typeof window !== 'undefined') {
setIsLoading(false);
@ -182,11 +187,7 @@ const EPGsTable = () => {
>
<MaterialReactTable table={table} />
<EPGForm
epg={epg}
isOpen={epgModalOpen}
onClose={() => setEPGModalOpen(false)}
/>
<EPGForm epg={epg} isOpen={epgModalOpen} onClose={closeEPGForm} />
<Snackbar
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}

View file

@ -1,18 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
MaterialReactTable,
MRT_ShowHideColumnsButton,
MRT_ToggleFullScreenButton,
useMaterialReactTable,
} from 'material-react-table';
import {
Box,
Grid2,
Stack,
Typography,
IconButton,
Tooltip,
Checkbox,
Select,
MenuItem,
} from '@mui/material';
@ -106,7 +102,9 @@ const Example = () => {
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 = () => {
<M3UForm
playlist={playlist}
isOpen={playlistModalOpen}
onClose={() => setPlaylistModalOpen(false)}
onClose={closeModal}
/>
</Box>
);

View file

@ -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 = () => {
<StreamProfileForm
profile={profile}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
onClose={closeStreamProfileForm}
/>
</Box>
);

View file

@ -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 = () => {
<StreamForm
stream={stream}
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onClose={closeStreamForm}
/>
</Box>
);

View file

@ -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 = () => {
<UserAgentForm
userAgent={userAgent}
isOpen={userAgentModalOpen}
onClose={() => setUserAgentModalOpen(false)}
onClose={closeUserAgentForm}
/>
</>
);

View file

@ -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();

View file

@ -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
})),
}));