Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest

This commit is contained in:
SergeantPanda 2025-08-19 12:39:21 -05:00
commit 2903773c86
21 changed files with 1972 additions and 785 deletions

View file

@ -28,6 +28,8 @@ from core.utils import acquire_task_lock, release_task_lock, send_websocket_upda
logger = logging.getLogger(__name__)
MAX_EXTRACT_CHUNK_SIZE = 65536 # 64kb (base2)
def send_epg_update(source_id, action, progress, **kwargs):
"""Send WebSocket update about EPG download/parsing progress"""
@ -641,7 +643,11 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
# Reset file pointer and extract the content
gz_file.seek(0)
with open(extracted_path, 'wb') as out_file:
out_file.write(gz_file.read())
while True:
chunk = gz_file.read(MAX_EXTRACT_CHUNK_SIZE)
if not chunk or len(chunk) == 0:
break
out_file.write(chunk)
except Exception as e:
logger.error(f"Error extracting GZIP file: {e}", exc_info=True)
return None
@ -685,9 +691,13 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
return None
# Extract the first XML file
xml_content = zip_file.read(xml_files[0])
with open(extracted_path, 'wb') as out_file:
out_file.write(xml_content)
with zip_file.open(xml_files[0], "r") as xml_file:
while True:
chunk = xml_file.read(MAX_EXTRACT_CHUNK_SIZE)
if not chunk or len(chunk) == 0:
break
out_file.write(chunk)
logger.info(f"Successfully extracted zip file to: {extracted_path}")

View file

@ -3,27 +3,40 @@ from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent
import json
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
extra = 1
verbose_name = "M3U Filter"
verbose_name_plural = "M3U Filters"
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'vod_enabled_display', 'uploaded_file_link', 'created_at', 'updated_at')
list_filter = ('is_active', 'server_group', 'account_type')
search_fields = ('name', 'server_url', 'server_group__name')
list_display = (
"name",
"server_url",
"server_group",
"max_streams",
"is_active",
"user_agent_display",
"uploaded_file_link",
"created_at",
"updated_at",
)
list_filter = ("is_active", "server_group")
search_fields = ("name", "server_url", "server_group__name")
inlines = [M3UFilterInline]
actions = ['activate_accounts', 'deactivate_accounts']
actions = ["activate_accounts", "deactivate_accounts"]
# Handle both ForeignKey and ManyToManyField cases for UserAgent
def user_agent_display(self, obj):
if hasattr(obj, 'user_agent'): # ForeignKey case
if hasattr(obj, "user_agent"): # ForeignKey case
return obj.user_agent.user_agent if obj.user_agent else "None"
elif hasattr(obj, 'user_agents'): # ManyToManyField case
elif hasattr(obj, "user_agents"): # ManyToManyField case
return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None"
return "None"
user_agent_display.short_description = "User Agent(s)"
def vod_enabled_display(self, obj):
@ -40,31 +53,35 @@ class M3UAccountAdmin(admin.ModelAdmin):
def uploaded_file_link(self, obj):
if obj.uploaded_file:
return format_html("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)
return format_html(
"<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url
)
return "No file uploaded"
uploaded_file_link.short_description = "Uploaded File"
@admin.action(description='Activate selected accounts')
@admin.action(description="Activate selected accounts")
def activate_accounts(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description='Deactivate selected accounts')
@admin.action(description="Deactivate selected accounts")
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
# Add ManyToManyField for Django Admin (if applicable)
if hasattr(M3UAccount, 'user_agents'):
filter_horizontal = ('user_agents',) # Only for ManyToManyField
if hasattr(M3UAccount, "user_agents"):
filter_horizontal = ("user_agents",) # Only for ManyToManyField
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude')
list_filter = ('filter_type', 'exclude')
search_fields = ('regex_pattern',)
ordering = ('m3u_account',)
list_display = ("m3u_account", "filter_type", "regex_pattern", "exclude")
list_filter = ("filter_type", "exclude")
search_fields = ("regex_pattern",)
ordering = ("m3u_account",)
@admin.register(ServerGroup)
class ServerGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ("name",)
search_fields = ("name",)

View file

@ -1,18 +1,38 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet, M3UAccountProfileViewSet
from .api_views import (
M3UAccountViewSet,
M3UFilterViewSet,
ServerGroupViewSet,
RefreshM3UAPIView,
RefreshSingleM3UAPIView,
UserAgentViewSet,
M3UAccountProfileViewSet,
)
app_name = 'm3u'
app_name = "m3u"
router = DefaultRouter()
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
router.register(r'accounts\/(?P<account_id>\d+)\/profiles', M3UAccountProfileViewSet, basename='m3u-account-profiles')
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
router.register(
r"accounts\/(?P<account_id>\d+)\/profiles",
M3UAccountProfileViewSet,
basename="m3u-account-profiles",
)
router.register(
r"accounts\/(?P<account_id>\d+)\/filters",
M3UFilterViewSet,
basename="m3u-filters",
)
router.register(r"server-groups", ServerGroupViewSet, basename="server-group")
urlpatterns = [
path('refresh/', RefreshM3UAPIView.as_view(), name='m3u_refresh'),
path('refresh/<int:account_id>/', RefreshSingleM3UAPIView.as_view(), name='m3u_refresh_single'),
path("refresh/", RefreshM3UAPIView.as_view(), name="m3u_refresh"),
path(
"refresh/<int:account_id>/",
RefreshSingleM3UAPIView.as_view(),
name="m3u_refresh_single",
),
]
urlpatterns += router.urls

View file

@ -252,8 +252,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
@ -263,6 +261,23 @@ class M3UFilterViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]
return M3UFilter.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs["account_id"]
# # Get the M3UAccount instance for the account_id
# m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context["m3u_account"] = account_id
# Perform the actual save
serializer.save(m3u_account_id=account_id)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-07-22 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.AlterField(
model_name='m3ufilter',
name='filter_type',
field=models.CharField(choices=[('group', 'Group'), ('name', 'Stream Name'), ('url', 'Stream URL')], default='group', help_text='Filter based on either group title or stream name.', max_length=50),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-07-31 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0013_alter_m3ufilter_filter_type'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='m3ufilter',
name='order',
field=models.PositiveIntegerField(default=0),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-08-02 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0014_alter_m3ufilter_options_m3ufilter_order'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={},
),
migrations.AddField(
model_name='m3ufilter',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]

View file

@ -155,9 +155,11 @@ class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
("group", "Group Title"),
("group", "Group"),
("name", "Stream Name"),
("url", "Stream URL"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
@ -177,6 +179,8 @@ class M3UFilter(models.Model):
default=True,
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
custom_properties = models.TextField(null=True, blank=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
@ -226,9 +230,6 @@ class ServerGroup(models.Model):
return self.name
from django.db import models
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""

View file

@ -6,7 +6,6 @@ from core.models import UserAgent
from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
ChannelGroupSerializer,
)
import logging
import json
@ -17,11 +16,16 @@ logger = logging.getLogger(__name__)
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
channel_groups = ChannelGroupM3UAccountSerializer(source="m3u_account", many=True)
class Meta:
model = M3UFilter
fields = ["id", "filter_type", "regex_pattern", "exclude", "channel_groups"]
fields = [
"id",
"filter_type",
"regex_pattern",
"exclude",
"order",
"custom_properties",
]
class M3UAccountProfileSerializer(serializers.ModelSerializer):
@ -65,7 +69,7 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = M3UFilterSerializer(many=True, read_only=True)
filters = serializers.SerializerMethodField()
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@ -200,6 +204,10 @@ class M3UAccountSerializer(serializers.ModelSerializer):
return super().create(validated_data)
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""

File diff suppressed because it is too large Load diff

View file

@ -1,208 +1,217 @@
#!/usr/bin/env bash
set -e
set -euo pipefail
IFS=$'\n\t'
# Root check
if [[ $EUID -ne 0 ]]; then
echo "[ERROR] This script must be run as root." >&2
exit 1
fi
trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR
##############################################################################
# 0) Warning / Disclaimer
##############################################################################
echo "**************************************************************"
echo "WARNING: While we do not anticipate any problems, we disclaim all"
echo "responsibility for anything that happens to your machine."
echo ""
echo "This script is intended for **Debian-based operating systems only**."
echo "Running it on other distributions WILL cause unexpected issues."
echo ""
echo "This script is **NOT RECOMMENDED** for use on your primary machine."
echo "For safety and best results, we strongly advise running this inside a"
echo "clean virtual machine (VM) or LXC container environment."
echo ""
echo "Additionally, there is NO SUPPORT for this method; Docker is the only"
echo "officially supported way to run Dispatcharr."
echo "**************************************************************"
echo ""
echo "If you wish to proceed, type \"I understand\" and press Enter."
read user_input
if [ "$user_input" != "I understand" ]; then
echo "Exiting script..."
exit 1
fi
show_disclaimer() {
echo "**************************************************************"
echo "WARNING: While we do not anticipate any problems, we disclaim all"
echo "responsibility for anything that happens to your machine."
echo ""
echo "This script is intended for **Debian-based operating systems only**."
echo "Running it on other distributions WILL cause unexpected issues."
echo ""
echo "This script is **NOT RECOMMENDED** for use on your primary machine."
echo "For safety and best results, we strongly advise running this inside a"
echo "clean virtual machine (VM) or LXC container environment."
echo ""
echo "Additionally, there is NO SUPPORT for this method; Docker is the only"
echo "officially supported way to run Dispatcharr."
echo "**************************************************************"
echo ""
echo "If you wish to proceed, type \"I understand\" and press Enter."
read user_input
if [ "$user_input" != "I understand" ]; then
echo "Exiting script..."
exit 1
fi
}
##############################################################################
# 1) Configuration
##############################################################################
# Linux user/group under which Dispatcharr processes will run
DISPATCH_USER="dispatcharr"
DISPATCH_GROUP="dispatcharr"
# Where Dispatcharr source code should live
APP_DIR="/opt/dispatcharr"
# Git branch to clone (e.g., "main" or "dev")
DISPATCH_BRANCH="dev"
# PostgreSQL settings
POSTGRES_DB="dispatcharr"
POSTGRES_USER="dispatch"
POSTGRES_PASSWORD="secret"
# The port on which Nginx will listen for HTTP
NGINX_HTTP_PORT="9191"
# The TCP port for Daphné (Django Channels)
WEBSOCKET_PORT="8001"
# Directory inside /run/ for our socket; full path becomes /run/dispatcharr/dispatcharr.sock
GUNICORN_RUNTIME_DIR="dispatcharr"
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
configure_variables() {
DISPATCH_USER="dispatcharr"
DISPATCH_GROUP="dispatcharr"
APP_DIR="/opt/dispatcharr"
DISPATCH_BRANCH="main"
POSTGRES_DB="dispatcharr"
POSTGRES_USER="dispatch"
POSTGRES_PASSWORD="secret"
NGINX_HTTP_PORT="9191"
WEBSOCKET_PORT="8001"
GUNICORN_RUNTIME_DIR="dispatcharr"
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
PYTHON_BIN=$(command -v python3)
SYSTEMD_DIR="/etc/systemd/system"
NGINX_SITE="/etc/nginx/sites-available/dispatcharr"
}
##############################################################################
# 2) Install System Packages
##############################################################################
echo ">>> Installing system packages..."
apt-get update
apt-get install -y \
git \
curl \
wget \
build-essential \
gcc \
libpcre3-dev \
libpq-dev \
python3-dev \
python3-venv \
python3-pip \
nginx \
redis-server \
postgresql \
postgresql-contrib \
ffmpeg \
procps \
streamlink
install_packages() {
echo ">>> Installing system packages..."
apt-get update
declare -a packages=(
git curl wget build-essential gcc libpcre3-dev libpq-dev
python3-dev python3-venv python3-pip nginx redis-server
postgresql postgresql-contrib ffmpeg procps streamlink
)
apt-get install -y --no-install-recommends "${packages[@]}"
# Node.js setup (v23.x from NodeSource) - adjust version if needed
if ! command -v node >/dev/null 2>&1; then
echo ">>> Installing Node.js..."
curl -sL https://deb.nodesource.com/setup_23.x | bash -
apt-get install -y nodejs
fi
if ! command -v node >/dev/null 2>&1; then
echo ">>> Installing Node.js..."
curl -sL https://deb.nodesource.com/setup_23.x | bash -
apt-get install -y nodejs
fi
# Start & enable PostgreSQL and Redis
systemctl enable postgresql redis-server
systemctl start postgresql redis-server
systemctl enable --now postgresql redis-server
}
##############################################################################
# 3) Create Dispatcharr User/Group
# 3) Create User/Group
##############################################################################
if ! getent group "${DISPATCH_GROUP}" >/dev/null; then
echo ">>> Creating group: ${DISPATCH_GROUP}"
groupadd "${DISPATCH_GROUP}"
fi
if ! id -u "${DISPATCH_USER}" >/dev/null; then
echo ">>> Creating user: ${DISPATCH_USER}"
useradd -m -g "${DISPATCH_GROUP}" -s /bin/bash "${DISPATCH_USER}"
fi
create_dispatcharr_user() {
if ! getent group "$DISPATCH_GROUP" >/dev/null; then
groupadd "$DISPATCH_GROUP"
fi
if ! id -u "$DISPATCH_USER" >/dev/null; then
useradd -m -g "$DISPATCH_GROUP" -s /bin/bash "$DISPATCH_USER"
fi
}
##############################################################################
# 4) Configure PostgreSQL Database
# 4) PostgreSQL Setup
##############################################################################
echo ">>> Configuring PostgreSQL..."
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'\"" | grep -q 1 || \
su - postgres -c "psql -c \"CREATE DATABASE ${POSTGRES_DB};\""
setup_postgresql() {
echo ">>> Checking PostgreSQL database and user..."
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='${POSTGRES_USER}'\"" | grep -q 1 || \
su - postgres -c "psql -c \"CREATE USER ${POSTGRES_USER} WITH PASSWORD '${POSTGRES_PASSWORD}';\""
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
if [[ "$db_exists" != "1" ]]; then
echo ">>> Creating database '${POSTGRES_DB}'..."
sudo -u postgres createdb "$POSTGRES_DB"
else
echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation."
fi
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE ${POSTGRES_DB} TO ${POSTGRES_USER};\""
su - postgres -c "psql -c \"ALTER DATABASE ${POSTGRES_DB} OWNER TO ${POSTGRES_USER};\""
su - postgres -c "psql -d ${POSTGRES_DB} -c \"ALTER SCHEMA public OWNER TO ${POSTGRES_USER};\""
user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'")
if [[ "$user_exists" != "1" ]]; then
echo ">>> Creating user '${POSTGRES_USER}'..."
sudo -u postgres psql -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';"
else
echo ">>> User '${POSTGRES_USER}' already exists, skipping creation."
fi
echo ">>> Granting privileges..."
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;"
sudo -u postgres psql -c "ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;"
sudo -u postgres psql -d "$POSTGRES_DB" -c "ALTER SCHEMA public OWNER TO $POSTGRES_USER;"
}
##############################################################################
# 5) Clone or Update Dispatcharr Code
# 5) Clone Dispatcharr Repository
##############################################################################
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
clone_dispatcharr_repo() {
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
if [ ! -d "$APP_DIR" ]; then
mkdir -p "$APP_DIR"
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
fi
if [ ! -d "${APP_DIR}" ]; then
echo ">>> Cloning repository for the first time..."
mkdir -p "${APP_DIR}"
chown "${DISPATCH_USER}:${DISPATCH_GROUP}" "${APP_DIR}"
su - "${DISPATCH_USER}" -c "git clone -b ${DISPATCH_BRANCH} https://github.com/Dispatcharr/Dispatcharr.git ${APP_DIR}"
else
echo ">>> Updating existing repository..."
su - "${DISPATCH_USER}" <<EOSU
cd "${APP_DIR}"
if [ -d .git ]; then
if [ -d "$APP_DIR/.git" ]; then
echo ">>> Updating existing Dispatcharr repo..."
su - "$DISPATCH_USER" <<EOSU
cd "$APP_DIR"
git fetch origin
git checkout ${DISPATCH_BRANCH}
git pull origin ${DISPATCH_BRANCH}
else
echo "WARNING: .git directory missing, cannot perform update via git."
fi
EOSU
fi
git reset --hard HEAD
git fetch origin
git checkout $DISPATCH_BRANCH
git pull origin $DISPATCH_BRANCH
EOSU
else
echo ">>> Cloning Dispatcharr repo into ${APP_DIR}..."
rm -rf "$APP_DIR"/*
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
su - "$DISPATCH_USER" -c "git clone -b $DISPATCH_BRANCH https://github.com/Dispatcharr/Dispatcharr.git $APP_DIR"
fi
}
##############################################################################
# 6) Create Python Virtual Environment & Install Python Dependencies
# 6) Setup Python Environment
##############################################################################
echo ">>> Setting up Python virtual environment..."
su - "${DISPATCH_USER}" <<EOSU
cd "${APP_DIR}"
python3 -m venv env
setup_python_env() {
echo ">>> Setting up Python virtual environment..."
su - "$DISPATCH_USER" <<EOSU
cd "$APP_DIR"
$PYTHON_BIN -m venv env
source env/bin/activate
# Upgrade pip and install dependencies from requirements
pip install --upgrade pip
pip install -r requirements.txt
# Explicitly ensure Gunicorn is installed in the virtualenv
pip install gunicorn
EOSU
# 6a) Create a symlink for ffmpeg in the virtualenv's bin directory.
echo ">>> Linking ffmpeg into the virtual environment..."
ln -sf /usr/bin/ffmpeg ${APP_DIR}/env/bin/ffmpeg
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
}
##############################################################################
# 7) Build Frontend (React)
# 7) Build Frontend
##############################################################################
echo ">>> Building frontend..."
su - "${DISPATCH_USER}" <<EOSU
cd "${APP_DIR}/frontend"
build_frontend() {
echo ">>> Building frontend..."
su - "$DISPATCH_USER" <<EOSU
cd "$APP_DIR/frontend"
npm install --legacy-peer-deps
npm run build
EOSU
}
##############################################################################
# 8) Django Migrate & Collect Static
# 8) Django Migrations & Static
##############################################################################
echo ">>> Running Django migrations & collectstatic..."
su - "${DISPATCH_USER}" <<EOSU
cd "${APP_DIR}"
django_migrate_collectstatic() {
echo ">>> Running Django migrations & collectstatic..."
su - "$DISPATCH_USER" <<EOSU
cd "$APP_DIR"
source env/bin/activate
export POSTGRES_DB="${POSTGRES_DB}"
export POSTGRES_USER="${POSTGRES_USER}"
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD}"
export POSTGRES_DB="$POSTGRES_DB"
export POSTGRES_USER="$POSTGRES_USER"
export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"
export POSTGRES_HOST="localhost"
python manage.py migrate --noinput
python manage.py collectstatic --noinput
EOSU
}
##############################################################################
# 9) Create Systemd Service for Gunicorn
# 9) Configure Services & Nginx
##############################################################################
cat <<EOF >/etc/systemd/system/dispatcharr.service
configure_services() {
echo ">>> Creating systemd service files..."
# Gunicorn
cat <<EOF >${SYSTEMD_DIR}/dispatcharr.service
[Unit]
Description=Gunicorn for Dispatcharr
After=network.target postgresql.service redis-server.service
@ -211,36 +220,31 @@ After=network.target postgresql.service redis-server.service
User=${DISPATCH_USER}
Group=${DISPATCH_GROUP}
WorkingDirectory=${APP_DIR}
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
RuntimeDirectoryMode=0775
# Update PATH to include both the virtualenv and system binaries (for ffmpeg)
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
Environment="POSTGRES_HOST=localhost"
ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done'
ExecStart=${APP_DIR}/env/bin/gunicorn \\
--workers=4 \\
--worker-class=gevent \\
--timeout=300 \\
--bind unix:${GUNICORN_SOCKET} \\
dispatcharr.wsgi:application
Restart=always
KillMode=mixed
SyslogIdentifier=dispatcharr
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
##############################################################################
# 10) Create Systemd Service for Celery
##############################################################################
cat <<EOF >/etc/systemd/system/dispatcharr-celery.service
# Celery
cat <<EOF >${SYSTEMD_DIR}/dispatcharr-celery.service
[Unit]
Description=Celery Worker for Dispatcharr
After=network.target redis-server.service
@ -256,21 +260,18 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
Environment="POSTGRES_HOST=localhost"
Environment="CELERY_BROKER_URL=redis://localhost:6379/0"
ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr worker -l info
Restart=always
KillMode=mixed
SyslogIdentifier=dispatcharr-celery
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
##############################################################################
# 11) Create Systemd Service for Celery Beat (Optional)
##############################################################################
cat <<EOF >/etc/systemd/system/dispatcharr-celerybeat.service
# Celery Beat
cat <<EOF >${SYSTEMD_DIR}/dispatcharr-celerybeat.service
[Unit]
Description=Celery Beat Scheduler for Dispatcharr
After=network.target redis-server.service
@ -286,23 +287,20 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
Environment="POSTGRES_HOST=localhost"
Environment="CELERY_BROKER_URL=redis://localhost:6379/0"
ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr beat -l info
Restart=always
KillMode=mixed
SyslogIdentifier=dispatcharr-celerybeat
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
##############################################################################
# 12) Create Systemd Service for Daphné (WebSockets / Channels)
##############################################################################
cat <<EOF >/etc/systemd/system/dispatcharr-daphne.service
# Daphne
cat <<EOF >${SYSTEMD_DIR}/dispatcharr-daphne.service
[Unit]
Description=Daphne for Dispatcharr (ASGI)
Description=Daphne for Dispatcharr (ASGI/WebSockets)
After=network.target
Requires=dispatcharr.service
@ -315,47 +313,33 @@ Environment="POSTGRES_DB=${POSTGRES_DB}"
Environment="POSTGRES_USER=${POSTGRES_USER}"
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
Environment="POSTGRES_HOST=localhost"
ExecStart=${APP_DIR}/env/bin/daphne -b 0.0.0.0 -p ${WEBSOCKET_PORT} dispatcharr.asgi:application
Restart=always
KillMode=mixed
SyslogIdentifier=dispatcharr-daphne
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
##############################################################################
# 13) Configure Nginx
##############################################################################
echo ">>> Configuring Nginx at /etc/nginx/sites-available/dispatcharr.conf ..."
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
echo ">>> Creating Nginx config..."
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
server {
listen ${NGINX_HTTP_PORT};
# Proxy to Gunicorn socket for main HTTP traffic
location / {
include proxy_params;
proxy_pass http://unix:${GUNICORN_SOCKET};
}
# Serve Django static files
location /static/ {
alias ${APP_DIR}/static/;
}
# Serve React build assets
location /assets/ {
alias ${APP_DIR}/frontend/dist/assets/;
}
# Serve media files if any
location /media/ {
alias ${APP_DIR}/media/;
}
# WebSockets for Daphné
location /ws/ {
proxy_pass http://127.0.0.1:${WEBSOCKET_PORT};
proxy_http_version 1.1;
@ -368,46 +352,66 @@ server {
}
EOF
ln -sf /etc/nginx/sites-available/dispatcharr.conf /etc/nginx/sites-enabled/dispatcharr.conf
# Remove default site if it exists
if [ -f /etc/nginx/sites-enabled/default ]; then
rm -f /etc/nginx/sites-enabled/default
fi
echo ">>> Testing Nginx config..."
nginx -t
echo ">>> Restarting Nginx..."
systemctl restart nginx
systemctl enable nginx
ln -sf /etc/nginx/sites-available/dispatcharr.conf /etc/nginx/sites-enabled/dispatcharr.conf
[ -f /etc/nginx/sites-enabled/default ] && rm /etc/nginx/sites-enabled/default
nginx -t
systemctl restart nginx
systemctl enable nginx
}
##############################################################################
# 14) Start & Enable Services
# 10) Start Services
##############################################################################
echo ">>> Enabling systemd services..."
systemctl daemon-reload
systemctl enable dispatcharr
systemctl enable dispatcharr-celery
systemctl enable dispatcharr-celerybeat
systemctl enable dispatcharr-daphne
echo ">>> Restarting / Starting services..."
systemctl restart dispatcharr
systemctl restart dispatcharr-celery
systemctl restart dispatcharr-celerybeat
systemctl restart dispatcharr-daphne
start_services() {
echo ">>> Enabling and starting services..."
systemctl daemon-reexec
systemctl daemon-reload
systemctl enable --now dispatcharr dispatcharr-celery dispatcharr-celerybeat dispatcharr-daphne
}
##############################################################################
# Done!
# 11) Summary
##############################################################################
echo "================================================="
echo "Dispatcharr installation (or update) complete!"
echo "Nginx is listening on port ${NGINX_HTTP_PORT}."
echo "Gunicorn socket: ${GUNICORN_SOCKET}."
echo "WebSockets on port ${WEBSOCKET_PORT} (path /ws/)."
echo "You can check logs via 'sudo journalctl -u dispatcharr -f', etc."
echo "Visit http://<server_ip>:${NGINX_HTTP_PORT} in your browser."
echo "================================================="
show_summary() {
server_ip=$(ip route get 1 | awk '{print $7; exit}')
cat <<EOF
=================================================
Dispatcharr installation (or update) complete!
Nginx is listening on port ${NGINX_HTTP_PORT}.
Gunicorn socket: ${GUNICORN_SOCKET}.
WebSockets on port ${WEBSOCKET_PORT} (path /ws/).
You can check logs via:
sudo journalctl -u dispatcharr -f
sudo journalctl -u dispatcharr-celery -f
sudo journalctl -u dispatcharr-celerybeat -f
sudo journalctl -u dispatcharr-daphne -f
Visit the app at:
http://${server_ip}:${NGINX_HTTP_PORT}
=================================================
EOF
}
##############################################################################
# Run Everything
##############################################################################
main() {
show_disclaimer
configure_variables
install_packages
create_dispatcharr_user
setup_postgresql
clone_dispatcharr_repo
setup_python_env
build_frontend
django_migrate_collectstatic
configure_services
start_services
show_summary
}
main "$@"

View file

@ -256,7 +256,7 @@ export default class API {
hasChannels: false,
hasM3UAccounts: false,
canEdit: true,
canDelete: true
canDelete: true,
};
useChannelsStore.getState().addChannelGroup(processedGroup);
// Refresh channel groups to update the UI
@ -736,10 +736,13 @@ export default class API {
static async updateM3UGroupSettings(playlistId, groupSettings) {
try {
const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, {
method: 'PATCH',
body: { group_settings: groupSettings },
});
const response = await request(
`${host}/api/m3u/accounts/${playlistId}/group-settings/`,
{
method: 'PATCH',
body: { group_settings: groupSettings },
}
);
// Fetch the updated playlist and update the store
const updatedPlaylist = await API.getPlaylist(playlistId);
usePlaylistsStore.getState().updatePlaylist(updatedPlaylist);
@ -863,7 +866,6 @@ export default class API {
body = { ...payload };
delete body.file;
}
console.log(body);
const response = await request(`${host}/api/m3u/accounts/${id}/`, {
method: 'PATCH',
@ -1119,6 +1121,48 @@ export default class API {
}
}
static async addM3UFilter(accountId, values) {
try {
const response = await request(
`${host}/api/m3u/accounts/${accountId}/filters/`,
{
method: 'POST',
body: values,
}
);
return response;
} catch (e) {
errorNotification(`Failed to add profile to account ${accountId}`, e);
}
}
static async deleteM3UFilter(accountId, id) {
try {
await request(`${host}/api/m3u/accounts/${accountId}/filters/${id}/`, {
method: 'DELETE',
});
} catch (e) {
errorNotification(`Failed to delete profile for account ${accountId}`, e);
}
}
static async updateM3UFilter(accountId, filterId, values) {
const { id, ...payload } = values;
try {
await request(
`${host}/api/m3u/accounts/${accountId}/filters/${filterId}/`,
{
method: 'PUT',
body: payload,
}
);
} catch (e) {
errorNotification(`Failed to update profile for account ${accountId}`, e);
}
}
static async getSettings() {
try {
const response = await request(`${host}/api/core/settings/`);
@ -1239,7 +1283,9 @@ export default class API {
static async getLogos(params = {}) {
try {
const queryParams = new URLSearchParams(params);
const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`);
const response = await request(
`${host}/api/channels/logos/?${queryParams.toString()}`
);
return response;
} catch (e) {
@ -1378,7 +1424,7 @@ export default class API {
});
// Remove multiple logos from store
ids.forEach(id => {
ids.forEach((id) => {
useChannelsStore.getState().removeLogo(id);
});

View file

@ -27,6 +27,7 @@ import usePlaylistsStore from '../../store/playlists';
import { notifications } from '@mantine/notifications';
import { isNotEmpty, useForm } from '@mantine/form';
import useEPGsStore from '../../store/epgs';
import M3UFilters from './M3UFilters';
const M3U = ({
m3uAccount = null,
@ -45,6 +46,7 @@ const M3U = ({
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [filterModalOpen, setFilterModalOpen] = useState(false);
const [loadingText, setLoadingText] = useState('');
const [showCredentialFields, setShowCredentialFields] = useState(false);
@ -86,7 +88,11 @@ const M3U = ({
account_type: m3uAccount.account_type,
username: m3uAccount.username ?? '',
password: '',
stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7,
stale_stream_days:
m3uAccount.stale_stream_days !== undefined &&
m3uAccount.stale_stream_days !== null
? m3uAccount.stale_stream_days
: 7,
enable_vod: m3uAccount.enable_vod || false,
});
@ -147,7 +153,8 @@ const M3U = ({
if (values.account_type != 'XC') {
notifications.show({
title: 'Fetching M3U Groups',
message: 'Configure group filters and auto sync settings once complete.',
message:
'Configure group filters and auto sync settings once complete.',
});
// Don't prompt for group filters, but keeping this here
@ -179,7 +186,10 @@ const M3U = ({
const closeGroupFilter = () => {
setGroupFilterModalOpen(false);
close();
};
const closeFilter = () => {
setFilterModalOpen(false);
};
useEffect(() => {
@ -193,214 +203,238 @@ const M3U = ({
}
return (
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
<LoadingOverlay
visible={form.submitting}
overlayBlur={2}
loaderProps={loadingText ? { children: loadingText } : {}}
/>
<>
<Modal size={700} opened={isOpen} onClose={close} title="M3U Account">
<LoadingOverlay
visible={form.submitting}
overlayBlur={2}
loaderProps={loadingText ? { children: loadingText } : {}}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="name"
name="name"
label="Name"
description="Unique identifier for this M3U account"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<TextInput
style={{ width: '100%' }}
id="server_url"
name="server_url"
label="URL"
description="Direct URL to the M3U playlist or server"
{...form.getInputProps('server_url')}
key={form.key('server_url')}
/>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="name"
name="name"
label="Name"
description="Unique identifier for this M3U account"
{...form.getInputProps('name')}
key={form.key('name')}
/>
<TextInput
style={{ width: '100%' }}
id="server_url"
name="server_url"
label="URL"
description="Direct URL to the M3U playlist or server"
{...form.getInputProps('server_url')}
key={form.key('server_url')}
/>
<Select
id="account_type"
name="account_type"
label="Account Type"
description={<>Standard for direct M3U URLs, <br />Xtream Codes for panel-based services</>}
data={[
{
value: 'STD',
label: 'Standard',
},
{
value: 'XC',
label: 'Xtream Codes',
},
]}
key={form.key('account_type')}
{...form.getInputProps('account_type')}
/>
<Select
id="account_type"
name="account_type"
label="Account Type"
description={
<>
Standard for direct M3U URLs, <br />
Xtream Codes for panel-based services
</>
}
data={[
{
value: 'STD',
label: 'Standard',
},
{
value: 'XC',
label: 'Xtream Codes',
},
]}
key={form.key('account_type')}
{...form.getInputProps('account_type')}
/>
{form.getValues().account_type == 'XC' && (
<Box>
{!m3uAccount && (
<Group justify="space-between">
<Box>Create EPG</Box>
<Switch
id="create_epg"
name="create_epg"
description="Automatically create matching EPG source for this Xtream account"
key={form.key('create_epg')}
{...form.getInputProps('create_epg', {
type: 'checkbox',
})}
/>
</Group>
)}
{form.getValues().account_type == 'XC' && (
<Box>
{!m3uAccount && (
<Group justify="space-between">
<Box>Create EPG</Box>
<Box>Enable VOD Scanning</Box>
<Switch
id="create_epg"
name="create_epg"
description="Automatically create matching EPG source for this Xtream account"
key={form.key('create_epg')}
{...form.getInputProps('create_epg', {
id="enable_vod"
name="enable_vod"
description="Scan and import VOD content (movies/series) from this Xtream account"
key={form.key('enable_vod')}
{...form.getInputProps('enable_vod', {
type: 'checkbox',
})}
/>
</Group>
)}
<Group justify="space-between">
<Box>Enable VOD Scanning</Box>
<Switch
id="enable_vod"
name="enable_vod"
description="Scan and import VOD content (movies/series) from this Xtream account"
key={form.key('enable_vod')}
{...form.getInputProps('enable_vod', {
type: 'checkbox',
})}
<TextInput
id="username"
name="username"
label="Username"
description="Username for Xtream Codes authentication"
{...form.getInputProps('username')}
/>
</Group>
<TextInput
id="username"
name="username"
label="Username"
description="Username for Xtream Codes authentication"
{...form.getInputProps('username')}
/>
<PasswordInput
id="password"
name="password"
label="Password"
description="Password for Xtream Codes authentication (leave empty to keep existing)"
{...form.getInputProps('password')}
/>
</Box>
)}
{form.getValues().account_type != 'XC' && (
<FileInput
id="file"
label="Upload files"
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
/>
)}
</Stack>
<Divider size="sm" orientation="vertical" />
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
description="Maximum number of concurrent streams (0 for unlimited)"
{...form.getInputProps('max_streams')}
key={form.key('max_streams')}
/>
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
description="User-Agent header to use when accessing this M3U source"
{...form.getInputProps('user_agent')}
key={form.key('user_agent')}
data={[{ value: '0', label: '(Use Default)' }].concat(
userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
}))
<PasswordInput
id="password"
name="password"
label="Password"
description="Password for Xtream Codes authentication (leave empty to keep existing)"
{...form.getInputProps('password')}
/>
</Box>
)}
/>
<NumberInput
label="Refresh Interval (hours)"
description={<>How often to automatically refresh M3U data<br />
(0 to disable automatic refreshes)</>}
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
/>
{form.getValues().account_type != 'XC' && (
<FileInput
id="file"
label="Upload files"
placeholder="Upload files"
description="Upload a local M3U file instead of using URL"
onChange={setFile}
/>
)}
</Stack>
<NumberInput
min={0}
max={365}
label="Stale Stream Retention (days)"
description="Streams not seen for this many days will be removed"
{...form.getInputProps('stale_stream_days')}
/>
<Divider size="sm" orientation="vertical" />
<Checkbox
label="Is Active"
description="Enable or disable this M3U account"
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
/>
</Stack>
</Group>
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
style={{ width: '100%' }}
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
description="Maximum number of concurrent streams (0 for unlimited)"
{...form.getInputProps('max_streams')}
key={form.key('max_streams')}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setGroupFilterModalOpen(true)}
>
Groups
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
</>
)}
<Select
id="user_agent"
name="user_agent"
label="User-Agent"
description="User-Agent header to use when accessing this M3U source"
{...form.getInputProps('user_agent')}
key={form.key('user_agent')}
data={[{ value: '0', label: '(Use Default)' }].concat(
userAgents.map((ua) => ({
label: ua.name,
value: `${ua.id}`,
}))
)}
/>
<Button
type="submit"
variant="filled"
disabled={form.submitting}
size="sm"
>
Save
</Button>
</Flex>
{playlist && (
<>
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<M3UGroupFilter
isOpen={groupFilterModalOpen}
playlist={playlist}
onClose={closeGroupFilter}
/>
</>
)}
</form>
</Modal>
<NumberInput
label="Refresh Interval (hours)"
description={
<>
How often to automatically refresh M3U data
<br />
(0 to disable automatic refreshes)
</>
}
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
/>
<NumberInput
min={0}
max={365}
label="Stale Stream Retention (days)"
description="Streams not seen for this many days will be removed"
{...form.getInputProps('stale_stream_days')}
/>
<Checkbox
label="Is Active"
description="Enable or disable this M3U account"
{...form.getInputProps('is_active', { type: 'checkbox' })}
key={form.key('is_active')}
/>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<>
<Button
variant="filled"
size="sm"
onClick={() => setFilterModalOpen(true)}
>
Filters
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setGroupFilterModalOpen(true)}
>
Groups
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
size="sm"
onClick={() => setProfileModalOpen(true)}
>
Profiles
</Button>
</>
)}
<Button
type="submit"
variant="filled"
disabled={form.submitting}
size="sm"
>
Save
</Button>
</Flex>
</form>
</Modal>
{playlist && (
<>
<M3UProfiles
playlist={playlist}
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
/>
<M3UGroupFilter
isOpen={groupFilterModalOpen}
playlist={playlist}
onClose={closeGroupFilter}
/>
<M3UFilters
isOpen={filterModalOpen}
playlist={playlist}
onClose={closeFilter}
/>
</>
)}
</>
);
};

View file

@ -0,0 +1,142 @@
// Modal.js
import React, { useEffect } from 'react';
import API from '../../api';
import {
TextInput,
Button,
Modal,
Flex,
Select,
Group,
Stack,
Switch,
Box,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { M3U_FILTER_TYPES } from '../../constants';
import usePlaylistsStore from '../../store/playlists';
import { setCustomProperty } from '../../utils';
const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const form = useForm({
mode: 'uncontrolled',
initialValues: {
filter_type: 'group',
regex_pattern: '',
exclude: true,
case_sensitive: true,
},
validate: (values) => ({}),
});
useEffect(() => {
if (filter) {
form.setValues({
filter_type: filter.filter_type,
regex_pattern: filter.regex_pattern,
exclude: filter.exclude,
case_sensitive:
JSON.parse(filter.custom_properties || '{}').case_sensitive ?? true,
});
} else {
form.reset();
}
}, [filter]);
const onSubmit = async () => {
const values = form.getValues();
values.custom_properties = setCustomProperty(
filter ? filter.custom_properties : {},
'case_sensitive',
values.case_sensitive,
true
);
delete values.case_sensitive;
if (!filter) {
// By default, new rule will go at the end
values.order = m3u.filters.length;
await API.addM3UFilter(m3u.id, values);
} else {
await API.updateM3UFilter(m3u.id, filter.id, values);
}
const updatedPlaylist = await fetchPlaylist(m3u.id);
form.reset();
onClose(updatedPlaylist);
};
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Filter">
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="xs" style={{ flex: 1 }}>
<Select
label="Field"
description="Specify which property of the stream object this rule will apply to"
data={M3U_FILTER_TYPES}
{...form.getInputProps('filter_type')}
key={form.key('filter_type')}
/>
<TextInput
id="regex_pattern"
name="regex_pattern"
label="Regex Pattern"
description="Regular expression to execute on the value to determine if the filter applies to the item"
{...form.getInputProps('regex_pattern')}
key={form.key('regex_pattern')}
/>
<Group justify="space-between">
<Box>Exclude</Box>
<Switch
id="exclude"
name="exclude"
description="Specify if this is an exclusion or inclusion rule"
key={form.key('exclude')}
{...form.getInputProps('exclude', {
type: 'checkbox',
})}
/>
</Group>
<Group justify="space-between">
<Box>Case Sensitive</Box>
<Switch
id="case_sensitive"
name="case_sensitive"
description="If the regex should be case sensitive or not"
key={form.key('case_sensitive')}
{...form.getInputProps('case_sensitive', {
type: 'checkbox',
})}
/>
</Group>
</Stack>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
disabled={form.submitting}
size="small"
>
Save
</Button>
</Flex>
</form>
</Modal>
);
};
export default M3UFilter;

View file

@ -0,0 +1,340 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import usePlaylistsStore from '../../store/playlists';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import {
Flex,
Modal,
Button,
Box,
ActionIcon,
Text,
useMantineTheme,
Center,
Group,
Alert,
} from '@mantine/core';
import { GripHorizontal, Info, SquareMinus, SquarePen } from 'lucide-react';
import M3UFilter from './M3UFilter';
import { M3U_FILTER_TYPES } from '../../constants';
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
const RowDragHandleCell = ({ rowId }) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: rowId,
});
return (
<Center>
<ActionIcon
ref={setNodeRef}
{...listeners}
{...attributes}
variant="transparent"
size="xs"
style={{
cursor: 'grab', // this is enough
}}
>
<GripHorizontal color="white" />
</ActionIcon>
</Center>
);
};
// Row Component
const DraggableRow = ({ filter, editFilter, onDelete }) => {
const theme = useMantineTheme();
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: filter.id,
});
const style = {
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: 'relative',
};
return (
<Box
ref={setNodeRef}
key={filter.id}
spacing="xs"
style={{
...style,
padding: '8px',
paddingBottom: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: '#2A2A2E',
flexDirection: 'column',
alignItems: 'stretch',
marginBottom: 5,
}}
>
<Flex gap="sm" justify="space-between" alignItems="middle">
<Group justify="left">
<RowDragHandleCell rowId={filter.id} />
<Text
size="sm"
fw={700}
style={{
color: filter.exclude
? theme.tailwind.red[6]
: theme.tailwind.green[5],
}}
>
{filter.exclude ? 'Exclude' : 'Include'}
</Text>
<Text size="sm">
{
M3U_FILTER_TYPES.find((type) => type.value == filter.filter_type)
.label
}
</Text>
<Text size="sm">matching</Text>
<Text size="sm">
"<code>{filter.regex_pattern}</code>"
</Text>
</Group>
<Group align="flex-end" gap="xs">
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editFilter(filter)}
>
<SquarePen size="20" />
</ActionIcon>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => onDelete(filter.id)}
size="small"
variant="transparent"
>
<SquareMinus size="20" />
</ActionIcon>
</Group>
</Flex>
</Box>
);
};
const M3UFilters = ({ playlist, isOpen, onClose }) => {
const theme = useMantineTheme();
const [editorOpen, setEditorOpen] = useState(false);
const [filter, setFilter] = useState(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [filterToDelete, setFilterToDelete] = useState(null);
const [filters, setFilters] = useState([]);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
useEffect(() => {
setFilters(playlist.filters || []);
}, [playlist]);
const editFilter = (filter = null) => {
if (filter) {
setFilter(filter);
}
setEditorOpen(true);
};
const onDelete = async (id) => {
if (!playlist || !playlist.id) return;
// Get profile details for the confirmation dialog
const filterObj = playlist.filters.find((p) => p.id === id);
setFilterToDelete(filterObj);
setDeleteTarget(id);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-filter')) {
return deleteFilter(id);
}
setConfirmDeleteOpen(true);
};
const deleteFilter = async (id) => {
if (!playlist || !playlist.id) return;
try {
await API.deleteM3UFilter(playlist.id, id);
setConfirmDeleteOpen(false);
} catch (error) {
console.error('Error deleting profile:', error);
setConfirmDeleteOpen(false);
}
fetchPlaylist(playlist.id);
setFilters(filters.filter((f) => f.id !== id));
};
const closeEditor = (updatedPlaylist = null) => {
setFilter(null);
setEditorOpen(false);
if (updatedPlaylist) {
setFilters(updatedPlaylist.filters);
}
};
const handleDragEnd = async ({ active, over }) => {
if (!over || active.id === over.id) return;
const originalFilters = [...filters];
const oldIndex = filters.findIndex((f) => f.id === active.id);
const newIndex = filters.findIndex((f) => f.id === over.id);
const newFilters = arrayMove(filters, oldIndex, newIndex);
setFilters(newFilters);
// Recalculate and compare order
const updatedFilters = newFilters.map((filter, index) => ({
...filter,
newOrder: index,
}));
// Filter only those whose order actually changed
const changedFilters = updatedFilters.filter((f) => f.order !== f.newOrder);
// Send updates
try {
await Promise.all(
changedFilters.map((f) =>
API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder })
)
);
await fetchPlaylist(playlist.id);
} catch (e) {
setFilters(originalFilters);
}
};
// Don't render if modal is not open, or if playlist data is invalid
if (!isOpen || !playlist || !playlist.id) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} title="Filters" size="lg">
<Alert
icon={<Info size={16} />}
color="blue"
variant="light"
style={{ marginBottom: 5 }}
>
<Text size="sm">
<strong>Order Matters!</strong> Rules are processed in the order
below. Once a stream matches a given rule, no other rules are
checked.
</Text>
</Alert>
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={filters.map(({ id }) => id)}
strategy={verticalListSortingStrategy}
>
{filters.map((filter) => (
<DraggableRow
key={filter.id}
filter={filter}
editFilter={editFilter}
onDelete={onDelete}
/>
))}
</SortableContext>
</DndContext>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => editFilter()}
style={{ width: '100%' }}
>
New
</Button>
</Flex>
</Modal>
<M3UFilter
m3u={playlist}
filter={filter}
isOpen={editorOpen}
onClose={closeEditor}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => deleteFilter(deleteTarget)}
title="Confirm Filter Deletion"
message={
filterToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following filter?
Type: ${filterToDelete.type}
Patter: ${filterToDelete.regex_pattern}
This action cannot be undone.`}
</div>
) : (
'Are you sure you want to delete this filter? This action cannot be undone.'
)
}
confirmLabel="Delete"
cancelLabel="Cancel"
actionKey="delete-filter"
onSuppressChange={suppressWarning}
size="md"
/>
</>
);
};
export default M3UFilters;

View file

@ -803,7 +803,12 @@ const M3UTable = () => {
return (
<Box>
<Flex
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 10 }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 10,
}}
gap={15}
>
<Text
@ -853,8 +858,7 @@ const M3UTable = () => {
padding: 0,
// gap: 1,
}}
>
</Box>
></Box>
</Paper>
<Box

View file

@ -33,19 +33,23 @@ export const NETWORK_ACCESS_OPTIONS = {
export const PROXY_SETTINGS_OPTIONS = {
buffering_timeout: {
label: 'Buffering Timeout',
description: 'Maximum time (in seconds) to wait for buffering before switching streams',
description:
'Maximum time (in seconds) to wait for buffering before switching streams',
},
buffering_speed: {
label: 'Buffering Speed',
description: 'Speed threshold below which buffering is detected (1.0 = normal speed)',
description:
'Speed threshold below which buffering is detected (1.0 = normal speed)',
},
redis_chunk_ttl: {
label: 'Buffer Chunk TTL',
description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
description:
'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
},
channel_shutdown_delay: {
label: 'Channel Shutdown Delay',
description: 'Delay in seconds before shutting down a channel after last client disconnects',
description:
'Delay in seconds before shutting down a channel after last client disconnects',
},
channel_init_grace_period: {
label: 'Channel Initialization Grace Period',
@ -53,6 +57,21 @@ export const PROXY_SETTINGS_OPTIONS = {
},
};
export const M3U_FILTER_TYPES = [
{
label: 'Group',
value: 'group',
},
{
label: 'Stream Name',
value: 'name',
},
{
label: 'Stream URL',
value: 'url',
},
];
export const REGION_CHOICES = [
{ value: 'ad', label: 'AD' },
{ value: 'ae', label: 'AE' },

View file

@ -19,6 +19,26 @@ const usePlaylistsStore = create((set) => ({
editPlaylistId: id,
})),
fetchPlaylist: async (id) => {
set({ isLoading: true, error: null });
try {
const playlist = await api.getPlaylist(id);
set((state) => ({
playlists: state.playlists.map((p) => (p.id == id ? playlist : p)),
isLoading: false,
profiles: {
...state.profiles,
[id]: playlist.profiles,
},
}));
return playlist;
} catch (error) {
console.error('Failed to fetch playlists:', error);
set({ error: 'Failed to load playlists.', isLoading: false });
}
},
fetchPlaylists: async () => {
set({ isLoading: true, error: null });
try {
@ -91,9 +111,11 @@ const usePlaylistsStore = create((set) => ({
const existingProgress = state.refreshProgress[accountId];
// Don't replace 'initializing' status with empty/early server messages
if (existingProgress &&
if (
existingProgress &&
existingProgress.action === 'initializing' &&
accountIdOrData.progress === 0) {
accountIdOrData.progress === 0
) {
return state; // Keep showing 'initializing' until real progress comes
}

View file

@ -89,3 +89,30 @@ export const copyToClipboard = async (value) => {
return false;
}
};
export const setCustomProperty = (input, key, value, serialize = false) => {
let obj;
if (input == null) {
// matches null or undefined
obj = {};
} else if (typeof input === 'string') {
try {
obj = JSON.parse(input);
} catch (e) {
obj = {};
}
} else if (typeof input === 'object' && !Array.isArray(input)) {
obj = { ...input }; // shallow copy
} else {
obj = {};
}
obj[key] = value;
if (serialize === true) {
return JSON.stringify(obj);
}
return obj;
};

View file

@ -1,8 +1,6 @@
Django==5.2.4
psycopg2-binary==2.9.10
redis==6.2.0
celery
celery[redis]
celery[redis]==5.5.3
djangorestframework==3.16.0
requests==2.32.4
psutil==7.0.0
@ -25,7 +23,7 @@ tzlocal
torch==2.7.1+cpu
# ML/NLP dependencies
sentence-transformers==5.0.0
sentence-transformers==5.1.0
channels
channels-redis==4.3.0
django-filter

View file

@ -1,5 +1,5 @@
"""
Dispatcharr version information.
"""
__version__ = '0.7.1' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__version__ = '0.8.0' # Follow semantic versioning (MAJOR.MINOR.PATCH)
__timestamp__ = None # Set during CI/CD build process