Merge pull request #1162 from Dispatcharr/dev

Dispatcharr - v0.22.0
This commit is contained in:
SergeantPanda 2026-03-31 21:38:34 -05:00 committed by GitHub
commit 56ff3a78b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 6359 additions and 1662 deletions

View file

@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- Updated `requests` 2.32.5 → 2.33.0, resolving the following CVE:
- **CVE-2026-25645** (moderate): Insecure temp file reuse in `extract_zipped_paths()` utility function.
- Updated frontend npm dependencies to resolve 4 audit vulnerabilities (2 moderate, 2 high):
- Updated `brace-expansion` 5.0.2 → 5.0.5, resolving **moderate** zero-step sequence causing process hang and memory exhaustion ([GHSA-f886-m6hf-6m8v](https://github.com/advisories/GHSA-f886-m6hf-6m8v))
- Updated `flatted` 3.4.1 → 3.4.2, resolving **high** Prototype Pollution via `parse()` in NodeJS flatted ([GHSA-rf6f-7fwh-wjgh](https://github.com/advisories/GHSA-rf6f-7fwh-wjgh))
- Updated `picomatch` 4.0.3 → 4.0.4, resolving **high** method injection in POSIX character classes causing incorrect glob matching ([GHSA-3v7f-55p6-f55p](https://github.com/advisories/GHSA-3v7f-55p6-f55p)) and a ReDoS vulnerability via extglob quantifiers ([GHSA-c2c7-rcm5-vvqj](https://github.com/advisories/GHSA-c2c7-rcm5-vvqj))
- Updated `yaml` 1.10.2 → 1.10.3, resolving **moderate** stack overflow via deeply nested YAML collections ([GHSA-48c2-rrv3-qjmp](https://github.com/advisories/GHSA-48c2-rrv3-qjmp))
### Added
- Connection cards on the Stats page now show the **username** of the connected user. For live channel connections a new User column appears between IP Address and Connected; for VOD connections the username is shown inline next to the IP address in the Client summary row. The username is resolved from the user store using the `user_id` stored in Redis client metadata. (Closes #766, Closes #586)
- `ip_address` and `user_id` were not included in the client info returned by `get_detailed_channel_info()` despite being available in the Redis hash. Both fields are now extracted and returned. `user_id` is now also included in the VOD stats response.
- Web UI stream preview now sends an `Authorization: Bearer` header with each mpegts.js request, identifying the logged-in user. Live channel previews initiated from the web UI now appear on the Stats page with the correct username rather than as unknown user.
- `client_connect` and `client_disconnect` system events now include the **username** of the connected user. The username is stored alongside the client metadata in Redis and included in the event payload for `log_system_event` calls (making it available to webhook and script integrations).
- Donate button added to the sidebar footer. A heart icon links to the project's Open Collective page, visible in both expanded and collapsed states. Hovering shows a "Support Dispatcharr" tooltip. The version string is also now clickable to copy it to the clipboard.
- User stream limits: administrators can now set a maximum number of concurrent streams per user account. When a user reaches their limit, the system can automatically terminate an existing stream to free a slot based on configurable rules. Limit enforcement applies to both live channels and VOD. (Closes #544)
- Each user account has a new **Stream Limit** field (0 = unlimited) configurable from the user edit form in Settings → Users.
- Global enforcement behaviour is configurable in Settings → User Limits:
- **Terminate on Limit Exceeded**: automatically stop an existing stream when the user's limit is reached (vs. rejecting the new connection).
- **Terminate Oldest**: prefer terminating the oldest stream when freeing a slot; disable to prefer the newest.
- **Prioritize Single-Client Channels**: prefer terminating streams on channels that only this user is watching.
- **Ignore Same-Channel Connections**: count multiple connections to the same live channel as one stream toward the limit. Same-channel reconnects are always allowed through. When this is enabled and a channel must be freed, all connections to the chosen channel are terminated together so that the unique-channel count actually decreases. VOD is explicitly excluded from this bypass since VOD connections are not shared upstream.
- TLS and mutual TLS (mTLS) support for Redis and PostgreSQL connections in modular deployments. Supports encrypted connections, server certificate verification (Redis: on/off; PostgreSQL: verify-full, verify-ca, require), CA certificate configuration, and client certificate authentication. Configured via environment variables in the docker compose file. Includes startup validation for certificate paths and TLS/URL scheme conflicts, and a read-only Connection Security panel in System Settings. (Closes #950) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- Status filter for M3U group and VOD category filter modals: A new **All / Enabled / Disabled** segmented control is now shown alongside the text search input in the Live, VOD - Movies, and VOD - Series tabs of the M3U Group Filter modal. The status filter works in combination with the text search and also scopes the "Select Visible" / "Deselect Visible" buttons so they only act on the currently visible subset. (Closes #312)
### Changed
- M3U Profile form (XC accounts): added a **Simple / Advanced** mode toggle for credential-based URL rewriting. In Simple mode users enter just a new username and password; the search and replace patterns are built automatically from the account's current credentials. In Advanced mode the full regex fields are shown as before. The selected mode is saved to `custom_properties.xcMode` and auto-detected on existing profiles (a profile whose search pattern matches the account's current `username/password` is recognised as Simple automatically). The Live Regex Demonstration panel is hidden in Simple mode.
- XtreamCodes VOD endpoints (`/movie/` and `/series/`) no longer redirect clients to a UUID-based proxy URL. Requests are now handled directly in the proxy layer via `stream_xc_movie` and `stream_xc_episode`, which call `stream_vod()` internally. The original XC path is preserved for the client throughout the stream.
- `CustomTable` column layout now supports flexible (`grow`) columns alongside fixed-width ones:
- Column definitions accept a `grow` property (boolean or number) to opt into flex layout. A numeric value sets the flex-grow weight, allowing relative sizing between grow columns (e.g. `grow: 2` gives a column twice the share of spare space as `grow: 1`).
- `maxSize` is now respected on grow columns, capping how wide they expand via `maxWidth`.
- The wrapper's `minWidth` calculation now uses `minSize` (not TanStack's 150px default) for grow columns, preventing the table from overflowing its container when columns would otherwise be sized larger than available space.
- Dependency updates:
- `requests` 2.32.5 → 2.33.0 (security patch; see Security section)
- `celery` 5.6.2 → 5.6.3
- `torch` 2.10.0+cpu → 2.11.0+cpu
- `sentence-transformers` 5.2.3 → 5.3.0
- `yt-dlp` 2026.3.13 → 2026.3.17
- Docker base image cleanup: removed `python-is-python3`, `python3-pip`, and `streamlink` from the apt package list in `DispatcharrBase`. `python3-pip` and `streamlink` were pulling outdated system Python packages (e.g. `requests 2.31.0`, `cryptography 41.0.7`, `lxml 5.2.1`) into the system Python's site-packages despite the app running entirely in the uv-managed venv at `/dispatcharrpy`. `streamlink` is already installed in the venv via `pyproject.toml`. `python-is-python3` is unnecessary as `PATH` resolves bare `python` to the venv binary.
- M3U table **Max Streams** column now reflects the combined limit across all active profiles. When a playlist has multiple active profiles, the column displays their summed total (or ∞ if any profile is unlimited) and a hover tooltip lists each profile's individual limit by name. (Closes #816)
- Toggling an M3U profile's active state now immediately updates the playlist store (including the `playlists` array), so the **Max Streams** total in the M3U table reflects the change without a page reload.
- M3U account form: **Max Streams** field changed from a plain text input to a number input with increment/decrement controls, consistent with other integer fields.
- M3U account form: removed unused `useMantineTheme` import and `theme` variable.
- Moved `guideUtils.js` from `frontend/src/pages/` to `frontend/src/utils/` to be consistent with other utility modules (e.g. `networkUtils.js`). Updated all imports across `GuideRow.jsx`, `HourTimeline.jsx`, `ProgramDetailModal.jsx`, `RecordingCardUtils.js`, `Guide.jsx`, and related test files.
- Frontend cleanup: removed unused imports from `M3UGroupFilter`, `LiveGroupFilter`, and `VODCategoryFilter` (`Yup`, `M3UProfiles`, several unused Mantine components, dead `OptionWithTooltip` component, duplicate lucide-react imports, and `Divider` in `VODCategoryFilter`). No behaviour changes.
- Network Access settings: leaving a field blank no longer shows a validation error. The default CIDR range for that field is saved automatically and a "Defaults Restored" warning is displayed listing which fields were reset. (Closes #726)
### Fixed
- M3U profile URL rewriting now uses the `regex` module instead of `re` across all URL transform code paths (`url_utils.transform_url`, `core/views.py`, `vod_proxy/_transform_url`, `tasks.get_transformed_credentials`, and the WebSocket live-preview handler in `consumers.py`). The `regex` module natively accepts JavaScript/PCRE-style named capture groups (`(?<name>...)`) without any conversion, eliminating the root cause of patterns that matched in the frontend live preview but failed on the backend with a `re.error`. As a further improvement, `regex` also supports variable-length lookbehind assertions (e.g. `(?<=a+)`), which `re` rejects with an error; patterns using these will now work correctly on the backend as well. Replace-pattern JS tokens are still normalised before calling `regex.sub`: `$<name>``\g<name>` and `$1`/`$2`/… → `\1`/`\2`/… (Python replacement syntax). Also fixed a bug in the WebSocket preview handler where a pattern error was incorrectly returning the search pattern string as the preview output instead of the original URL. (Fixes #1005)
- Web UI stream preview (`FloatingVideo`) was calling `mpegts.createPlayer()` with all `Config` options (e.g. `enableWorker`, `liveSync`, `headers`) merged into the first `MediaDataSource` argument. mpegts.js only reads `Config` from the optional second argument; unrecognised fields in the first are silently ignored. As a result all player configuration was effectively the library defaults — worker offloading was disabled, latency management had no effect, and the `Authorization: Bearer` header (required for user identification) was never sent. Fixed by splitting into the correct two-argument call. Both `liveBufferLatencyChasing` and `liveSync` have been disabled, eliminating playback-rate fluctuations that caused audible stuttering on live streams. SourceBuffer cleanup thresholds were also relaxed from 10s/5s to 120s/60s to prevent frequent SourceBuffer pauses.
- HTML named entities in XMLTV EPG files are now correctly preserved during lxml parsing. Some EPG providers (particularly French and other European sources) use HTML named entities like `&eacute;`, `&icirc;`, `&uuml;` in channel names, program titles, and metadata. These are not valid XML entities — lxml 6.0.2 with `recover=True` silently drops them, causing characters to go missing (e.g., "Chaîne Télé" becomes "Chane Tl"). This is now fixed by injecting an XML `<!DOCTYPE tv [...]>` internal subset declaring all 252 HTML 4 named entities directly into the byte stream that lxml reads, using a lightweight in-memory wrapper (`_PrependStream`) with zero disk I/O. libxml2 resolves the entities during its normal C-level parse pass — no Python-level preprocessing or temporary files are involved. The DOCTYPE block (~8 KB) is built once at module load from Python's stdlib `html.entities.name2codepoint` and reused for every parse. Files that already declare their own `<!DOCTYPE>` are passed through unchanged. (Closes #1095) — Thanks [@CodeBormen](https://github.com/CodeBormen) for helping with this!
- Duplicate recordings created when EPG sources refresh and re-evaluate series rules (Fixes #940) — Thanks [@CodeBormen](https://github.com/CodeBormen):
- **Program ID instability**: `parse_programs_for_source()` deletes and recreates all `ProgramData` rows with new auto-increment IDs on every EPG refresh. The dedup set used these IDs, so it never matched after a refresh. Deduplication now uses a stable `(tvg_id, start_time, end_time)` composite key sourced from `Recording.custom_properties.program`.
- **Secondary guard using wrong times**: The DB guard compared unadjusted program times against offset-adjusted `Recording.start_time`/`end_time`, so it never matched when any DVR pre/post offset was configured. It now queries `custom_properties__program__start_time/end_time` (the original, unadjusted program times stored at recording creation).
- **No concurrency guard**: Each EPG source refresh fired `evaluate_series_rules.delay()` independently. Concurrent tasks loaded the dedup set before others committed, allowing races. Evaluation is now serialized with `acquire_task_lock` (reusing the existing EPG task pattern). Gracefully degrades if Redis is unavailable — the primary and secondary dedup guards still protect.
- EPG refresh tasks (`refresh_epg_data`) were being killed mid-transaction on large EPG sources. The `soft_time_limit=1700s` introduced in v0.21.0 raised `SoftTimeLimitExceeded`, a subclass of `Exception`, which was swallowed by the existing `except Exception` handler in `parse_programs_for_source`, leaving the database in a partial state with no logged error. `soft_time_limit` has been removed from `refresh_epg_data` and `time_limit` raised to 14400s (4 hours) as a true last-resort ceiling; the existing `TaskLockRenewer` daemon thread continues to renew the Redis lock every 120s for legitimately long-running tasks.
## [0.21.1] - 2026-03-18
### Fixed

View file

@ -287,11 +287,10 @@ class UserViewSet(viewsets.ModelViewSet):
if request.method == "PATCH":
ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"}
disallowed = set(request.data.keys()) - ALLOWED_FIELDS
if disallowed:
return Response(
{"detail": f"Fields not allowed for self-update: {', '.join(disallowed)}"},
status=400,
)
for key in disallowed:
request.data.pop(key, None)
serializer = UserSerializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-03-19 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_alter_user_managers'),
]
operations = [
migrations.AddField(
model_name='user',
name='stream_limit',
field=models.IntegerField(default=0),
),
]

View file

@ -30,6 +30,7 @@ class User(AbstractUser):
user_level = models.IntegerField(default=UserLevel.STREAMER)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
stream_limit = models.IntegerField(default=0)
def __str__(self):
return self.username

View file

@ -65,6 +65,7 @@ class UserSerializer(serializers.ModelSerializer):
"channel_profiles",
"custom_properties",
"avatar_config",
"stream_limit",
"is_staff",
"is_superuser",
"last_login",

View file

@ -28,10 +28,36 @@ def _is_postgresql() -> bool:
def _get_pg_env() -> dict:
"""Get environment variables for PostgreSQL commands."""
"""Get environment variables for PostgreSQL commands.
Includes PGPASSWORD for password auth and PGSSL* variables for TLS.
Reads TLS config from DATABASES['default']['OPTIONS'], which is
populated by settings.py when POSTGRES_SSL=true.
"""
db_config = settings.DATABASES["default"]
env = os.environ.copy()
env["PGPASSWORD"] = db_config.get("PASSWORD", "")
password = db_config.get("PASSWORD", "")
if password:
env["PGPASSWORD"] = password
else:
env.pop("PGPASSWORD", None)
# Propagate TLS configuration from Django OPTIONS to libpq env vars.
options = db_config.get("OPTIONS", {})
_ssl_env_map = {
"sslmode": "PGSSLMODE",
"sslrootcert": "PGSSLROOTCERT",
"sslcert": "PGSSLCERT",
"sslkey": "PGSSLKEY",
}
# Always strip inherited PGSSL* vars first, then set only what is explicitly configured
for opt_key, env_key in _ssl_env_map.items():
env.pop(env_key, None)
value = options.get(opt_key)
if value:
env[env_key] = value
return env

View file

@ -15,6 +15,96 @@ from . import services
User = get_user_model()
class PgEnvTlsTestCase(TestCase):
"""Test that _get_pg_env includes TLS and password env vars correctly."""
databases = []
@patch('apps.backups.services.settings')
def test_pg_env_includes_ssl_vars_when_tls_enabled(self, mock_settings):
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "testpass",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {
"sslmode": "verify-full",
"sslrootcert": "/certs/ca.crt",
"sslcert": "/certs/client.crt",
"sslkey": "/certs/client.key",
},
}
}
env = services._get_pg_env()
self.assertEqual(env["PGSSLMODE"], "verify-full")
self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt")
self.assertEqual(env["PGSSLCERT"], "/certs/client.crt")
self.assertEqual(env["PGSSLKEY"], "/certs/client.key")
self.assertEqual(env["PGPASSWORD"], "testpass")
@patch('apps.backups.services.settings')
def test_pg_env_no_ssl_vars_when_tls_disabled(self, mock_settings):
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "testpass",
"HOST": "localhost",
"PORT": 5432,
}
}
env = services._get_pg_env()
self.assertNotIn("PGSSLMODE", env)
self.assertNotIn("PGSSLROOTCERT", env)
self.assertNotIn("PGSSLCERT", env)
self.assertNotIn("PGSSLKEY", env)
self.assertEqual(env["PGPASSWORD"], "testpass")
@patch('apps.backups.services.settings')
def test_pg_env_no_password_when_empty(self, mock_settings):
"""Cert-only auth: PGPASSWORD must not be set when password is empty."""
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {"sslmode": "verify-full"},
}
}
env = services._get_pg_env()
self.assertNotIn("PGPASSWORD", env)
self.assertEqual(env["PGSSLMODE"], "verify-full")
@patch('apps.backups.services.settings')
def test_pg_env_partial_ssl_options(self, mock_settings):
"""Server-only TLS: only sslmode and CA cert, no client cert/key."""
mock_settings.DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "testdb",
"USER": "testuser",
"PASSWORD": "pass",
"HOST": "localhost",
"PORT": 5432,
"OPTIONS": {
"sslmode": "verify-ca",
"sslrootcert": "/certs/ca.crt",
},
}
}
env = services._get_pg_env()
self.assertEqual(env["PGSSLMODE"], "verify-ca")
self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt")
self.assertNotIn("PGSSLCERT", env)
self.assertNotIn("PGSSLKEY", env)
class BackupServicesTestCase(TestCase):
"""Test cases for backup services"""

View file

@ -2674,7 +2674,49 @@ class RecordingViewSet(viewsets.ModelViewSet):
recording_id = instance.pk
channel_name = instance.channel.name
# Capture state before the DB row is deleted
# Attempt to close the DVR client connection for this channel if active
try:
channel_uuid = str(instance.channel.uuid)
# Lazy imports to avoid module overhead if proxy isn't used
from core.utils import RedisClient
from apps.proxy.ts_proxy.redis_keys import RedisKeys
from apps.proxy.ts_proxy.services.channel_service import ChannelService
r = RedisClient.get_client()
if r:
client_set_key = RedisKeys.clients(channel_uuid)
client_ids = r.smembers(client_set_key) or []
stopped = 0
for cid in client_ids:
try:
meta_key = RedisKeys.client_metadata(channel_uuid, cid)
ua = r.hget(meta_key, "user_agent")
# Identify DVR recording client by its user agent
if ua and "Dispatcharr-DVR" in ua:
try:
ChannelService.stop_client(channel_uuid, cid)
stopped += 1
except Exception as inner_e:
logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}")
except Exception as inner:
logger.debug(f"Error while checking client metadata: {inner}")
if stopped:
logger.info(f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation")
# If no clients remain after stopping DVR clients, proactively stop the channel
try:
remaining = r.scard(client_set_key) or 0
except Exception:
remaining = 0
if remaining == 0:
try:
ChannelService.stop_channel(channel_uuid)
logger.info(f"Stopped channel {channel_uuid} (no clients remain)")
except Exception as sc_e:
logger.debug(f"Unable to stop channel {channel_uuid}: {sc_e}")
except Exception as e:
logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}")
# Capture paths before deletion
cp = instance.custom_properties or {}
rec_status = cp.get("status", "")
file_path = cp.get("file_path")

View file

@ -204,7 +204,7 @@ class Stream(models.Model):
return stream_profile
def get_stream(self):
def get_stream(self, requester=None):
"""
Finds an available profile for this stream and reserves a connection slot.
@ -400,6 +400,144 @@ class Channel(models.Model):
return stream_profile
def _pick_channel_to_preempt(
self,
profile_id,
requester_level,
redis_client,
exclude_channel_ids=None,
cooldown_seconds=30,
):
"""
Pick the lowest-impact channel to terminate on the given profile.
Returns: Optional[int] channel_id to preempt
"""
exclude_channel_ids = set(exclude_channel_ids or [])
candidates = []
# 1) Try to get active channel IDs for this profile from an index set if available
ch_set_key = f"ts_proxy:profile:{profile_id}:channels"
try:
ch_ids = { (int(x) if not isinstance(x, int) else x) for x in (redis_client.smembers(ch_set_key) or set()) }
except Exception:
ch_ids = set()
logger.debug("Candidate channels for preemption:")
logger.debug(ch_ids)
# 2) Fallback: scan metadata keys and filter by m3u_profile == profile_id
if not ch_ids:
cursor = 0
pattern = "ts_proxy:channel:*:metadata"
while True:
cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=500)
if keys:
# Prefer HGET m3u_profile if metadata is a hash
pipe = redis_client.pipeline()
for k in keys:
pipe.hget(k, "m3u_profile")
prof_vals = pipe.execute()
for k, prof_val in zip(keys, prof_vals):
try:
pid = int(prof_val) if prof_val is not None else None
except Exception:
pid = None
if pid == profile_id:
parts = k.split(":") # ts_proxy:channel:{id}:metadata
if len(parts) >= 4:
try:
ch_ids.add(int(parts[2]))
except Exception:
pass
if cursor == 0:
break
logger.debug("Candidate channels for preemption:")
logger.debug(ch_ids)
if not ch_ids:
return None
# 3) Score candidates
for ch_id in ch_ids:
if ch_id in exclude_channel_ids:
continue
# Skip if recently preempted
last_preempt_key = f"ts_proxy:channel:{ch_id}:last_preempt"
try:
last_preempt = float(redis_client.get(last_preempt_key) or 0.0)
except Exception:
last_preempt = 0.0
if last_preempt and (time.time() - last_preempt) < cooldown_seconds:
continue
# Clients and their levels
clients_key = f"ts_proxy:channel:{ch_id}:clients"
member_ids = list(redis_client.smembers(clients_key) or [])
viewer_count = len(member_ids)
max_viewer_level = 0
if viewer_count:
pipe = redis_client.pipeline()
for cid in member_ids:
pipe.hget(f"ts_proxy:channel:{ch_id}:clients:{cid}", "user_level")
levels_raw = pipe.execute()
levels = []
for lv in levels_raw:
try:
levels.append(int(lv or 0))
except Exception:
levels.append(0)
max_viewer_level = max(levels or [0])
# Only preempt if requester strictly outranks this channel's viewers
if requester_level <= max_viewer_level:
continue
# Metadata (protected/recording/started_at_ts)
meta_key = f"ts_proxy:channel:{ch_id}:metadata"
try:
protected, recording, started_at_ts = redis_client.hmget(
meta_key, "protected", "recording", "started_at_ts"
)
except Exception:
protected = recording = started_at_ts = None
protected = str(protected or "0") in ("1", "true", "True")
recording = str(recording or "0") in ("1", "true", "True")
if protected or recording:
continue
try:
started_at_ts = float(started_at_ts) if started_at_ts is not None else None
except Exception:
started_at_ts = None
if started_at_ts is None:
started_at_ts = time.time() # treat unknown as newest
# Score: lower is safer to terminate
has_viewers = 1 if viewer_count > 0 else 0
score = (has_viewers, max_viewer_level, viewer_count, started_at_ts)
candidates.append((score, ch_id))
logger.debug("Candidate channels after scoring:")
logger.debug(candidates)
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
victim_id = candidates[0][1]
# Mark preempt timestamp to avoid thrashing
try:
redis_client.set(f"ts_proxy:channel:{victim_id}:last_preempt", str(time.time()), ex=3600)
except Exception:
pass
return victim_id
def _check_and_reserve_profile_slot(self, profile, redis_client):
"""
Atomically check and reserve a connection slot for the given profile.
@ -432,7 +570,7 @@ class Channel(models.Model):
redis_client.decr(profile_connections_key)
return (False, new_count - 1)
def get_stream(self):
def get_stream(self, requester=None):
"""
Finds an available stream for the requested channel and returns the selected stream and profile.
@ -513,6 +651,17 @@ class Channel(models.Model):
None,
) # Return newly assigned stream and matched profile
else:
# At capacity: try to preempt a lower-impact channel on this profile
victim_channel_id = self._pick_channel_to_preempt(
profile_id=profile.id,
requester_level=requester.user_level if requester else 100,
redis_client=redis_client,
exclude_channel_ids=None,
)
if victim_channel_id:
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
# return self.id, profile.id, victim_channel_id
# This profile is at max connections
has_streams_but_maxed_out = True
logger.debug(

View file

@ -19,6 +19,7 @@ from rapidfuzz import fuzz
from apps.channels.models import Channel
from apps.epg.models import EPGData
from core.models import CoreSettings
from core.utils import acquire_task_lock, release_task_lock
from django.db import OperationalError, close_old_connections
from channels.layers import get_channel_layer
@ -1070,12 +1071,39 @@ def match_single_channel_epg(channel_id):
def evaluate_series_rules_impl(tvg_id: str | None = None):
"""Synchronous implementation of series rule evaluation; returns details for debugging."""
result = {"scheduled": 0, "details": []}
# Serialize all invocations to prevent concurrent evaluations from
# racing to create duplicate recordings (e.g. multiple EPG sources
# refreshing simultaneously each firing evaluate_series_rules.delay()).
# If Redis is unavailable, proceed without lock — the primary and
# secondary dedup guards still prevent duplicates.
lock_acquired = False
try:
lock_acquired = acquire_task_lock('evaluate_series_rules', 'all')
if not lock_acquired:
result["details"].append({"status": "skipped", "reason": "concurrent evaluation in progress"})
return result
except (ConnectionError, OSError, AttributeError):
logger.warning("Could not acquire series rule evaluation lock (Redis unavailable), proceeding without lock")
try:
return _evaluate_series_rules_locked(tvg_id, result)
finally:
if lock_acquired:
try:
release_task_lock('evaluate_series_rules', 'all')
except (ConnectionError, OSError, AttributeError):
logger.warning("Could not release series rule evaluation lock")
def _evaluate_series_rules_locked(tvg_id, result):
"""Inner implementation of series rule evaluation, called under lock."""
from django.utils import timezone
from apps.channels.models import Recording, Channel
from apps.epg.models import EPGData, ProgramData
rules = CoreSettings.get_dvr_series_rules()
result = {"scheduled": 0, "details": []}
if not isinstance(rules, list) or not rules:
return result
@ -1089,14 +1117,23 @@ def evaluate_series_rules_impl(tvg_id: str | None = None):
now = timezone.now()
horizon = now + timedelta(days=7)
# Preload existing recordings' program ids to avoid duplicates
existing_program_ids = set()
for rec in Recording.objects.all().only("custom_properties"):
# Preload existing recordings keyed by stable program attributes that
# survive EPG refreshes (tvg_id + original start/end times stored in
# custom_properties). ProgramData.id changes on every EPG refresh so
# it cannot be used for deduplication. Only load future recordings
# to bound the set size — past recordings cannot collide with newly
# scheduled future programs.
existing_program_keys = set()
for cp in Recording.objects.filter(
end_time__gte=now,
).values_list("custom_properties", flat=True):
try:
pid = rec.custom_properties.get("program", {}).get("id") if rec.custom_properties else None
if pid is not None:
# Normalize to string for consistent comparisons
existing_program_ids.add(str(pid))
prog_data = (cp or {}).get("program", {})
tvg_id_val = prog_data.get("tvg_id")
st = prog_data.get("start_time")
et = prog_data.get("end_time")
if tvg_id_val and st and et:
existing_program_keys.add((str(tvg_id_val), str(st), str(et)))
except Exception:
continue
@ -1191,17 +1228,21 @@ def evaluate_series_rules_impl(tvg_id: str | None = None):
created_here = 0
for prog in unique_programs:
try:
# Skip if already scheduled by program id
if str(prog.id) in existing_program_ids:
# Skip if a recording already exists for this exact airing
# (keyed by tvg_id + original program times, which are stable
# across EPG refreshes unlike ProgramData.id).
prog_key = (str(prog.tvg_id), prog.start_time.isoformat(), prog.end_time.isoformat())
if prog_key in existing_program_keys:
continue
# Extra guard: skip if a recording exists for the same channel + timeslot
# Extra guard: DB query using the same stable attributes
# stored in custom_properties (unadjusted program times,
# not offset-adjusted Recording.start_time/end_time).
try:
from django.db.models import Q
if Recording.objects.filter(
channel=channel,
start_time=prog.start_time,
end_time=prog.end_time,
).filter(Q(custom_properties__program__id=prog.id) | Q(custom_properties__program__title=prog.title)).exists():
custom_properties__program__tvg_id=prog.tvg_id,
custom_properties__program__start_time=prog.start_time.isoformat(),
custom_properties__program__end_time=prog.end_time.isoformat(),
).exists():
continue
except Exception:
continue # already scheduled/recorded
@ -1245,7 +1286,7 @@ def evaluate_series_rules_impl(tvg_id: str | None = None):
}
},
)
existing_program_ids.add(str(prog.id))
existing_program_keys.add(prog_key)
created_here += 1
try:
prefetch_recording_artwork.apply_async(args=[rec.id], countdown=1)
@ -2452,15 +2493,12 @@ def run_recording(recording_id, channel_id, start_time_str, end_time_str):
metadata_key = RedisKeys.channel_metadata(str(channel.uuid))
md = r.hgetall(metadata_key)
if md:
def _gv(bkey):
return md.get(bkey.encode('utf-8'))
def _d(bkey, cast=str):
v = _gv(bkey)
v = md.get(bkey)
try:
if v is None:
return None
s = v.decode('utf-8')
s = v
return cast(s) if cast is not str else s
except Exception:
return None

View file

@ -0,0 +1,718 @@
"""Tests for series rule evaluation deduplication.
Unit tests verify the dedup logic in evaluate_series_rules_impl.
Integration tests exercise the full path: EPG refresh series rule
evaluation Recording creation post_save signal chain.
"""
from datetime import timedelta
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, Recording
from apps.epg.models import EPGSource, EPGData, ProgramData
from core.models import CoreSettings
def _set_series_rules(rules):
"""Helper to store series rules in CoreSettings."""
CoreSettings.set_dvr_series_rules(rules)
def _set_dvr_offsets(pre_min=0, post_min=0):
"""Helper to store DVR pre/post offsets."""
CoreSettings._update_group("dvr_settings", "DVR Settings", {
"pre_offset_minutes": pre_min,
"post_offset_minutes": post_min,
})
class SeriesRuleDedupBaseTestCase(TestCase):
"""Shared setup for series rule dedup tests."""
def setUp(self):
self.now = timezone.now()
self.epg_source = EPGSource.objects.create(
name="Test EPG", source_type="xmltv"
)
self.epg = EPGData.objects.create(
tvg_id="test.channel.1",
name="Test Channel EPG",
epg_source=self.epg_source,
)
self.channel = Channel.objects.create(
channel_number=1, name="Test Channel", epg_data=self.epg
)
_set_series_rules([{
"tvg_id": "test.channel.1",
"mode": "all",
"title": "Test Show",
}])
_set_dvr_offsets(pre_min=0, post_min=0)
def _create_program(self, hours_from_now=1, title="Test Show",
sub_title="Episode 1", tvg_id="test.channel.1"):
"""Create a ProgramData at the given offset."""
start = self.now + timedelta(hours=hours_from_now)
end = start + timedelta(hours=1)
return ProgramData.objects.create(
epg=self.epg,
tvg_id=tvg_id,
start_time=start,
end_time=end,
title=title,
sub_title=sub_title,
)
def _simulate_epg_refresh(self, programs_data):
"""Delete all ProgramData and recreate with new IDs (simulates EPG refresh)."""
ProgramData.objects.filter(epg=self.epg).delete()
new_programs = []
for data in programs_data:
prog = ProgramData.objects.create(epg=self.epg, **data)
new_programs.append(prog)
return new_programs
def _program_data_for_refresh(self, prog):
"""Build the dict needed by _simulate_epg_refresh from a ProgramData."""
return {
"tvg_id": prog.tvg_id,
"start_time": prog.start_time,
"end_time": prog.end_time,
"title": prog.title,
"sub_title": prog.sub_title,
}
# ---------------------------------------------------------------------------
# Unit tests: dedup logic in evaluate_series_rules_impl
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class ProgramIdStabilityTests(SeriesRuleDedupBaseTestCase):
"""Verify dedup works after EPG refresh changes ProgramData IDs."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_no_duplicate_after_epg_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Same program should not be recorded twice after EPG refresh."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
old_id = prog.id
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
new_programs = self._simulate_epg_refresh(
[self._program_data_for_refresh(prog)]
)
self.assertNotEqual(old_id, new_programs[0].id)
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result2["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_no_duplicate_with_offsets_after_refresh(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Dedup works when DVR offsets shift Recording times away from program times."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=5)
prog = self._create_program(hours_from_now=2)
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
rec = Recording.objects.first()
self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5))
self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=5))
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_different_episodes_still_recorded(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Different episodes on the same channel should each get a recording."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2, sub_title="Episode 1")
self._create_program(hours_from_now=4, sub_title="Episode 2")
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 2)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_new_episode_after_refresh_is_recorded(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""A genuinely new episode appearing after EPG refresh should be recorded."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self._simulate_epg_refresh([
self._program_data_for_refresh(prog),
{
"tvg_id": "test.channel.1",
"start_time": prog.end_time,
"end_time": prog.end_time + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 2",
},
])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
self.assertEqual(result2["scheduled"], 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_multiple_epg_refreshes_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Multiple consecutive EPG refreshes should not accumulate duplicates."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
for _ in range(5):
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class ConcurrencyGuardTests(SeriesRuleDedupBaseTestCase):
"""Verify the task lock prevents concurrent evaluation."""
def test_lock_acquired_and_released(self, mock_schedule, mock_artwork):
"""evaluate_series_rules_impl acquires and releases the task lock."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock", return_value=True) as mock_lock, \
patch("apps.channels.tasks.release_task_lock") as mock_release:
evaluate_series_rules_impl()
mock_lock.assert_called_once_with('evaluate_series_rules', 'all')
mock_release.assert_called_once_with('evaluate_series_rules', 'all')
def test_skips_when_lock_held(self, mock_schedule, mock_artwork):
"""Returns early with skip reason when lock is already held."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock", return_value=False):
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 0)
self.assertTrue(
any(d.get("reason") == "concurrent evaluation in progress"
for d in result["details"]),
)
self.assertEqual(Recording.objects.count(), 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_lock_released_on_exception(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Lock is released even if the inner implementation raises."""
from apps.channels.tasks import evaluate_series_rules_impl
with patch("apps.channels.tasks._evaluate_series_rules_locked",
side_effect=RuntimeError("test error")):
with self.assertRaises(RuntimeError):
evaluate_series_rules_impl()
mock_release.assert_called_once_with('evaluate_series_rules', 'all')
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class SecondaryGuardTests(SeriesRuleDedupBaseTestCase):
"""Verify the secondary DB guard uses stable program attributes."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_secondary_guard_catches_duplicate_with_offsets(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Secondary guard works with stale program IDs and DVR offsets."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=10, post_min=10)
prog = self._create_program(hours_from_now=2)
# Pre-existing recording with a stale program ID (from previous EPG refresh)
Recording.objects.create(
channel=self.channel,
start_time=prog.start_time - timedelta(minutes=10),
end_time=prog.end_time + timedelta(minutes=10),
custom_properties={
"program": {
"id": 99999,
"tvg_id": prog.tvg_id,
"title": prog.title,
"start_time": prog.start_time.isoformat(),
"end_time": prog.end_time.isoformat(),
}
},
)
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result["scheduled"], 0)
# ---------------------------------------------------------------------------
# Integration tests: full path from EPG refresh through recording creation
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class IntegrationEPGRefreshTests(SeriesRuleDedupBaseTestCase):
"""End-to-end tests simulating the EPG refresh → evaluate → record flow.
These exercise the full signal chain: evaluate_series_rules_impl creates
a Recording, the post_save signal fires schedule_recording_task, and
subsequent evaluations (after EPG refresh) must not create duplicates.
"""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_single_episode_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Simulate: create rule → evaluate → EPG refresh → re-evaluate.
The full recording lifecycle must result in exactly 1 recording.
"""
from apps.channels.tasks import evaluate_series_rules_impl
# Initial EPG data
prog = self._create_program(hours_from_now=2, sub_title="Pilot")
# First evaluation creates the recording
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
# Verify the recording was created with correct program metadata
rec = Recording.objects.first()
self.assertEqual(rec.custom_properties["program"]["tvg_id"], "test.channel.1")
self.assertEqual(rec.custom_properties["program"]["title"], "Test Show")
self.assertEqual(
rec.custom_properties["program"]["start_time"],
prog.start_time.isoformat()
)
# Verify the post_save signal scheduled a task
mock_schedule.assert_called()
initial_schedule_count = mock_schedule.call_count
# Simulate EPG refresh (programs get new DB IDs)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
# Re-evaluate after refresh (this is what EPG refresh triggers)
result2 = evaluate_series_rules_impl()
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
# No additional task scheduling should have occurred
self.assertEqual(mock_schedule.call_count, initial_schedule_count)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_with_offsets_no_duplicates(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Full flow with DVR offsets: recording times differ from program times."""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=10)
prog = self._create_program(hours_from_now=3, sub_title="Episode 1")
result1 = evaluate_series_rules_impl()
self.assertEqual(result1["scheduled"], 1)
rec = Recording.objects.first()
# Verify offset-adjusted recording times
self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5))
self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=10))
# Verify original (unadjusted) program times in custom_properties
self.assertEqual(
rec.custom_properties["program"]["start_time"],
prog.start_time.isoformat()
)
self.assertEqual(
rec.custom_properties["program"]["end_time"],
prog.end_time.isoformat()
)
# EPG refresh + re-evaluate
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
self.assertEqual(result2["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_multiple_episodes_across_refreshes(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""New episodes appear across multiple EPG refreshes; each recorded once."""
from apps.channels.tasks import evaluate_series_rules_impl
ep1 = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh adds episode 2 alongside episode 1
ep1_data = self._program_data_for_refresh(ep1)
ep2_start = ep1.end_time
ep2_data = {
"tvg_id": "test.channel.1",
"start_time": ep2_start,
"end_time": ep2_start + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 2",
}
self._simulate_epg_refresh([ep1_data, ep2_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
# Another EPG refresh adds episode 3
ep3_start = ep2_start + timedelta(hours=1)
ep3_data = {
"tvg_id": "test.channel.1",
"start_time": ep3_start,
"end_time": ep3_start + timedelta(hours=1),
"title": "Test Show",
"sub_title": "Episode 3",
}
self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 3)
# Final EPG refresh with no new episodes — count must stay at 3
self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 3)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_multiple_series_rules(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Multiple series rules on different channels, each evaluated correctly."""
from apps.channels.tasks import evaluate_series_rules_impl
# Second channel with its own EPG
epg2 = EPGData.objects.create(
tvg_id="test.channel.2",
name="Channel 2 EPG",
epg_source=self.epg_source,
)
channel2 = Channel.objects.create(
channel_number=2, name="Test Channel 2", epg_data=epg2
)
_set_series_rules([
{"tvg_id": "test.channel.1", "mode": "all", "title": "Show A"},
{"tvg_id": "test.channel.2", "mode": "all", "title": "Show B"},
])
# Programs on both channels
start1 = self.now + timedelta(hours=2)
prog1 = ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Show A", sub_title="Episode 1",
)
start2 = self.now + timedelta(hours=3)
prog2 = ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Show B", sub_title="Episode 1",
)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2)
self.assertEqual(Recording.objects.filter(channel=self.channel).count(), 1)
self.assertEqual(Recording.objects.filter(channel=channel2).count(), 1)
# EPG refresh for both channels
ProgramData.objects.filter(epg=self.epg).delete()
ProgramData.objects.filter(epg=epg2).delete()
ProgramData.objects.create(
epg=self.epg, tvg_id="test.channel.1",
start_time=start1, end_time=start1 + timedelta(hours=1),
title="Show A", sub_title="Episode 1",
)
ProgramData.objects.create(
epg=epg2, tvg_id="test.channel.2",
start_time=start2, end_time=start2 + timedelta(hours=1),
title="Show B", sub_title="Episode 1",
)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 2,
"No duplicates across multiple series rules after EPG refresh")
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_rapid_epg_refreshes_simulate_user_report(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Reproduce the user-reported scenario: series rule + multiple EPG refreshes
causing count to balloon from 6 to 25 and 5 simultaneous recordings.
Simulates 6 episodes with 5 EPG refreshes (each assigning new ProgramData IDs).
"""
from apps.channels.tasks import evaluate_series_rules_impl
# Create 6 episodes (the user had "next of 6")
episodes = []
for i in range(6):
start = self.now + timedelta(hours=2 + i * 2)
episodes.append({
"tvg_id": "test.channel.1",
"start_time": start,
"end_time": start + timedelta(hours=1),
"title": "Test Show",
"sub_title": f"Episode {i + 1}",
})
# Create initial ProgramData
for ep in episodes:
ProgramData.objects.create(epg=self.epg, **ep)
# First evaluation: should create exactly 6 recordings
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 6)
# Simulate 5 EPG refreshes (the user saw count balloon to 25)
for refresh_num in range(5):
self._simulate_epg_refresh(episodes)
result = evaluate_series_rules_impl()
self.assertEqual(
Recording.objects.count(), 6,
f"After EPG refresh #{refresh_num + 1}, expected 6 recordings "
f"but got {Recording.objects.count()}"
)
self.assertEqual(result["scheduled"], 0)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_recording_survives_program_removal_and_readd(
self, mock_release, mock_lock, mock_schedule, mock_artwork
):
"""Program temporarily disappears from EPG then reappears — no duplicate."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2, sub_title="Episode 1")
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh removes the program entirely
self._simulate_epg_refresh([])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Existing recording preserved when program disappears from EPG")
# EPG refresh adds the program back (new ID)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"No duplicate when program reappears with new ID")
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_celery_task_wrapper_calls_impl(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""The @shared_task evaluate_series_rules delegates to _impl correctly."""
from apps.channels.tasks import evaluate_series_rules
self._create_program(hours_from_now=2)
result = evaluate_series_rules()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
# Call again (simulating a second EPG refresh trigger)
result2 = evaluate_series_rules()
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_tvg_id_scoped_evaluation(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Scoped evaluation (tvg_id parameter) still prevents duplicates."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
result1 = evaluate_series_rules_impl(tvg_id="test.channel.1")
self.assertEqual(result1["scheduled"], 1)
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result2 = evaluate_series_rules_impl(tvg_id="test.channel.1")
self.assertEqual(result2["scheduled"], 0)
self.assertEqual(Recording.objects.count(), 1)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_full_flow_offset_change_between_refreshes(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Changing DVR offsets between EPG refreshes doesn't create duplicates.
Even though Recording.start_time/end_time change when offsets change,
the dedup key uses the original program times from custom_properties.
"""
from apps.channels.tasks import evaluate_series_rules_impl
_set_dvr_offsets(pre_min=5, post_min=5)
prog = self._create_program(hours_from_now=2)
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
rec = Recording.objects.first()
original_start = rec.start_time
original_end = rec.end_time
# Change offsets
_set_dvr_offsets(pre_min=10, post_min=15)
# EPG refresh
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Changing offsets between refreshes should not create duplicates")
self.assertEqual(result["scheduled"], 0)
# ---------------------------------------------------------------------------
# Edge case tests: Redis unavailability, non-series recordings, robustness
# ---------------------------------------------------------------------------
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class RedisUnavailabilityTests(SeriesRuleDedupBaseTestCase):
"""Verify evaluation works when Redis is unavailable (lock cannot be acquired)."""
def test_proceeds_when_redis_down(self, mock_schedule, mock_artwork):
"""Evaluation succeeds (with dedup guards) when Redis raises on lock acquire."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 1)
def test_dedup_still_works_without_lock(self, mock_schedule, mock_artwork):
"""Dedup guards prevent duplicates even when the lock is unavailable."""
from apps.channels.tasks import evaluate_series_rules_impl
prog = self._create_program(hours_from_now=2)
# First call: Redis down, proceeds without lock
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1)
# EPG refresh
self._simulate_epg_refresh([self._program_data_for_refresh(prog)])
# Second call: Redis still down
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")):
result = evaluate_series_rules_impl()
self.assertEqual(Recording.objects.count(), 1,
"Dedup guards prevent duplicates even without lock")
self.assertEqual(result["scheduled"], 0)
def test_lock_not_released_when_not_acquired(self, mock_schedule, mock_artwork):
"""release_task_lock is not called if acquire raised an exception."""
from apps.channels.tasks import evaluate_series_rules_impl
self._create_program(hours_from_now=2)
with patch("apps.channels.tasks.acquire_task_lock",
side_effect=ConnectionError("Redis unavailable")), \
patch("apps.channels.tasks.release_task_lock") as mock_release:
evaluate_series_rules_impl()
mock_release.assert_not_called()
@patch("apps.channels.tasks.prefetch_recording_artwork")
@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id")
class NonSeriesRecordingTests(SeriesRuleDedupBaseTestCase):
"""Verify non-series recordings don't interfere with series rule dedup."""
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_manual_recording_without_program_data_ignored(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings without custom_properties.program are skipped by dedup key builder."""
from apps.channels.tasks import evaluate_series_rules_impl
# Manual recording with no program metadata
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties={},
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_recurring_rule_recording_does_not_interfere(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings from recurring rules (custom_properties.rule) don't block series rules."""
from apps.channels.tasks import evaluate_series_rules_impl
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties={"rule": {"id": 1, "name": "Daily News"}},
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)
self.assertEqual(Recording.objects.count(), 2)
@patch("apps.channels.tasks.acquire_task_lock", return_value=True)
@patch("apps.channels.tasks.release_task_lock")
def test_recording_with_null_custom_properties_ignored(self, mock_release, mock_lock,
mock_schedule, mock_artwork):
"""Recordings with None custom_properties don't crash the dedup key builder."""
from apps.channels.tasks import evaluate_series_rules_impl
Recording.objects.create(
channel=self.channel,
start_time=self.now + timedelta(hours=2),
end_time=self.now + timedelta(hours=3),
custom_properties=None,
)
prog = self._create_program(hours_from_now=2)
result = evaluate_series_rules_impl()
self.assertEqual(result["scheduled"], 1)

View file

@ -2,6 +2,7 @@
import logging
import gzip
import html.entities
import os
import uuid
import requests
@ -15,7 +16,7 @@ import zipfile
from celery import shared_task
from django.conf import settings
from django.db import transaction
from django.db import connection, transaction
from django.utils import timezone
from apps.channels.models import Channel
from core.models import UserAgent, CoreSettings
@ -28,6 +29,103 @@ from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, se
logger = logging.getLogger(__name__)
# DOCTYPE internal subset for XMLTV files. Declares all 252 HTML 4 named
# entities so lxml/libxml2 can resolve references like &eacute; correctly
# instead of silently dropping them in recovery mode.
# The 5 XML-predefined entities (amp, lt, gt, quot, apos) are always
# recognised by the XML spec and must not be redeclared.
_XML_ENTITIES = frozenset({'amp', 'lt', 'gt', 'quot', 'apos'})
def _build_html_entity_doctype() -> bytes:
"""Build a DOCTYPE internal subset declaring all HTML 4 named entities."""
lines = [b'<!DOCTYPE tv [\n']
for name, codepoint in sorted(html.entities.name2codepoint.items()):
if name not in _XML_ENTITIES:
# Numeric character references are always valid XML regardless of codepoint.
lines.append(f'<!ENTITY {name} "&#x{codepoint:X};">\n'.encode('ascii'))
lines.append(b']>\n')
return b''.join(lines)
_HTML_ENTITY_DOCTYPE = _build_html_entity_doctype()
class _PrependStream:
"""Wraps an open binary file and prepends a bytes prefix to its content.
Used by _open_xmltv_file to inject a DOCTYPE entity block before the
file content reaches lxml's iterparse, with zero disk I/O.
"""
__slots__ = ('_prefix', '_prefix_pos', '_file')
def __init__(self, prefix: bytes, file_obj):
self._prefix = prefix
self._prefix_pos = 0
self._file = file_obj
def read(self, size=-1):
prefix_len = len(self._prefix)
if self._prefix_pos >= prefix_len:
return self._file.read(size)
remaining = prefix_len - self._prefix_pos
if size < 0:
chunk = self._prefix[self._prefix_pos:] + self._file.read()
self._prefix_pos = prefix_len
return chunk
if size <= remaining:
chunk = self._prefix[self._prefix_pos:self._prefix_pos + size]
self._prefix_pos += size
return chunk
chunk = self._prefix[self._prefix_pos:]
self._prefix_pos = prefix_len
return chunk + self._file.read(size - remaining)
def close(self):
self._file.close()
def __enter__(self):
return self
def __exit__(self, *_):
self.close()
def _open_xmltv_file(file_path: str):
"""Open an XMLTV file for lxml iterparse, injecting an HTML entity DOCTYPE.
Prepends a <!DOCTYPE tv [...]> block that declares all 252 HTML 4 named
entities so lxml/libxml2 resolves references like &eacute; correctly
instead of silently dropping them in recovery mode. This involves zero
disk I/O the DOCTYPE is streamed in-memory before the file content.
If the file already contains a <!DOCTYPE> declaration the file is returned
unchanged; a second DOCTYPE would be invalid XML.
The caller is responsible for closing the returned object.
"""
f = open(file_path, 'rb')
start = f.read(512)
# Do not inject if the file already declares a DOCTYPE.
if b'<!DOCTYPE' in start or b'<!doctype' in start.lower():
f.seek(0)
return f
# Insert the DOCTYPE after the XML declaration if one is present.
stripped = start.lstrip()
if stripped.startswith(b'<?xml'):
decl_end = start.find(b'?>')
if decl_end >= 0:
xml_decl = start[:decl_end + 2]
f.seek(decl_end + 2)
return _PrependStream(xml_decl + b'\n' + _HTML_ENTITY_DOCTYPE, f)
# No XML declaration — insert DOCTYPE at the very start of the file.
f.seek(0)
return _PrependStream(_HTML_ENTITY_DOCTYPE, f)
def validate_icon_url_fast(icon_url, max_length=None):
"""
@ -146,7 +244,7 @@ def refresh_all_epg_data():
return "EPG data refreshed."
@shared_task(time_limit=1800, soft_time_limit=1700)
@shared_task(time_limit=14400)
def refresh_epg_data(source_id):
if not acquire_task_lock('refresh_epg_data', source_id):
logger.debug(f"EPG refresh for {source_id} already running")
@ -397,42 +495,41 @@ def fetch_xmltv(source):
# Download to temporary file
with open(temp_download_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=16384): # Increased chunk size for better performance
if chunk:
f.write(chunk)
for chunk in response.iter_content(chunk_size=16384):
f.write(chunk)
downloaded += len(chunk)
elapsed_time = time.time() - start_time
downloaded += len(chunk)
elapsed_time = time.time() - start_time
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
# Calculate download speed in KB/s
speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0
# Calculate progress percentage
if total_size and total_size > 0:
progress = min(100, int((downloaded / total_size) * 100))
else:
# If no content length header, estimate progress
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
# Calculate progress percentage
if total_size and total_size > 0:
progress = min(100, int((downloaded / total_size) * 100))
else:
# If no content length header, estimate progress
progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
# Time remaining (in seconds)
time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0
# Only send updates at specified intervals to avoid flooding
current_time = time.time()
if current_time - last_update_time >= update_interval and progress > 0:
last_update_time = current_time
send_epg_update(
source.id,
"downloading",
progress,
speed=round(speed, 2),
elapsed_time=round(elapsed_time, 1),
time_remaining=round(time_remaining, 1),
downloaded=f"{downloaded / (1024 * 1024):.2f} MB"
)
# Only send updates at specified intervals to avoid flooding
current_time = time.time()
if current_time - last_update_time >= update_interval and progress > 0:
last_update_time = current_time
send_epg_update(
source.id,
"downloading",
progress,
speed=round(speed, 2),
elapsed_time=round(elapsed_time, 1),
time_remaining=round(time_remaining, 1),
downloaded=f"{downloaded / (1024 * 1024):.2f} MB"
)
# Explicitly delete the chunk to free memory immediately
del chunk
# Explicitly delete the chunk to free memory immediately
del chunk
# Send completion notification
send_epg_update(source.id, "downloading", 100)
@ -526,6 +623,7 @@ def fetch_xmltv(source):
source.save(update_fields=['status'])
logger.info(f"Cached EPG file saved to {source.file_path}")
return True
except requests.exceptions.HTTPError as e:
@ -888,7 +986,7 @@ def parse_channels_only(source):
# Open the file - no need to check file type since it's always XML now
logger.debug(f"Opening file for channel parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
if process:
logger.debug(f"[parse_channels_only] Memory after opening file: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -1271,7 +1369,7 @@ def parse_programs_for_tvg_id(epg_id):
try:
# Open the file directly - no need to check compression
logger.debug(f"Opening file for parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
@ -1544,7 +1642,7 @@ def parse_programs_for_source(epg_source, tvg_id=None):
try:
logger.debug(f"Opening file for single-pass parsing: {file_path}")
source_file = open(file_path, 'rb')
source_file = _open_xmltv_file(file_path)
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
@ -1657,6 +1755,10 @@ def parse_programs_for_source(epg_source, tvg_id=None):
batch_size = 1000
try:
with transaction.atomic():
# Kill any individual statement that hangs longer than 10 minutes.
# SET LOCAL automatically resets when this transaction ends (commit or rollback).
with connection.cursor() as cursor:
cursor.execute("SET LOCAL statement_timeout = '10min'")
# Delete existing programs for mapped EPGs
deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0]
logger.debug(f"Deleted {deleted_count} existing programs")

View file

@ -0,0 +1,195 @@
import os
import tempfile
from django.test import TestCase
from apps.epg.tasks import (
_NAMED_ENTITY_RE,
_detect_xml_encoding,
_replace_html_entity,
_resolve_html_entities,
)
class ReplaceHtmlEntityTests(TestCase):
"""Tests for the regex callback that resolves individual HTML entities."""
def _sub(self, text):
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text)
def test_french_accented(self):
self.assertEqual(self._sub("Cha&icirc;ne T&eacute;l&eacute;"), "Chaîne Télé")
def test_german_umlauts(self):
self.assertEqual(self._sub("M&uuml;nchen &Uuml;bersicht &szlig;"), "München Übersicht ß")
def test_spanish(self):
self.assertEqual(self._sub("Espa&ntilde;a &iquest;Qu&eacute;?"), "España ¿Qué?")
def test_portuguese(self):
self.assertEqual(self._sub("Comunica&ccedil;&atilde;o"), "Comunicação")
def test_scandinavian(self):
self.assertEqual(self._sub("Norsk &oslash; &aring; &aelig;"), "Norsk ø å æ")
def test_greek_letters(self):
self.assertEqual(self._sub("&alpha;&beta;&gamma;"), "αβγ")
def test_currency_and_symbols(self):
self.assertEqual(self._sub("&copy; &euro; &pound; &yen;"), "© € £ ¥")
def test_preserves_xml_amp(self):
self.assertEqual(self._sub("A &amp; B"), "A &amp; B")
def test_preserves_xml_lt_gt(self):
self.assertEqual(self._sub("&lt;tag&gt;"), "&lt;tag&gt;")
def test_preserves_xml_quot_apos(self):
self.assertEqual(self._sub("&quot;hello&apos;"), "&quot;hello&apos;")
def test_preserves_uppercase_xml_entities(self):
"""&AMP;, &LT;, &GT;, &QUOT; resolve to XML-special chars; must not be replaced."""
self.assertEqual(self._sub("&AMP;"), "&AMP;")
self.assertEqual(self._sub("&LT;"), "&LT;")
self.assertEqual(self._sub("&GT;"), "&GT;")
self.assertEqual(self._sub("&QUOT;"), "&QUOT;")
def test_partial_entity_match_preserved(self):
"""html.unescape can partially match &amp inside &ampersand; — must not corrupt."""
self.assertEqual(self._sub("&ampersand;"), "&ampersand;")
def test_mixed_html_and_xml_entities(self):
self.assertEqual(
self._sub("R&eacute;sum&eacute; &amp; Co &lt;test&gt;"),
"Résumé &amp; Co &lt;test&gt;",
)
def test_plain_ascii_unchanged(self):
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text")
def test_direct_utf8_unchanged(self):
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ")
def test_unknown_entity_preserved(self):
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
class ResolveHtmlEntitiesFileTests(TestCase):
"""Tests for the file-level preprocessing function."""
def _make_file(self, content):
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
return path
def test_resolves_entities_in_file(self):
path = self._make_file(
'<?xml version="1.0"?>\n<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>'
)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("Télé", content)
self.assertNotIn("&eacute;", content)
def test_preserves_xml_entities_in_file(self):
path = self._make_file("<tv><desc>A &amp; B &lt;C&gt;</desc></tv>")
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("&amp;", content)
self.assertIn("&lt;", content)
self.assertIn("&gt;", content)
def test_no_temp_file_left_on_success(self):
path = self._make_file("<tv>test</tv>")
_resolve_html_entities(path)
self.assertFalse(os.path.exists(path + ".entity_tmp"))
def test_plain_file_unchanged(self):
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertEqual(content, original)
def test_utf8_content_preserved(self):
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
path = self._make_file(original)
_resolve_html_entities(path)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("日本語テレビ", content)
def test_iso_8859_1_encoding(self):
"""Files declaring ISO-8859-1 should be read in that encoding."""
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("Cha\u00eene", content)
self.assertNotIn("&icirc;", content)
def test_detect_encoding_utf8_default(self):
"""Headers without an encoding declaration default to UTF-8."""
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
def test_detect_encoding_iso_8859_1(self):
"""Encoding is read from the XML declaration."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
"ISO-8859-1",
)
def test_detect_encoding_single_quotes(self):
"""Encoding detection works with single-quoted attributes."""
self.assertEqual(
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
"windows-1252",
)
def test_detect_encoding_unknown_falls_back(self):
"""Unrecognized encoding falls back to UTF-8."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
"utf-8",
)
def test_iso_8859_1_with_entities_roundtrip(self):
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars."""
# Mix of direct ISO-8859-1 chars and HTML entities
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(xml_str.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved")
self.assertIn("\xe9mission", content, "Entity should be resolved")
self.assertNotIn("&eacute;", content)
def test_mismatched_encoding_leaves_file_untouched(self):
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(raw)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
original_bytes = raw # save for comparison
_resolve_html_entities(path)
with open(path, "rb") as f:
result_bytes = f.read()
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error")

View file

@ -1,6 +1,7 @@
# apps/m3u/tasks.py
import logging
import re
import regex
import requests
import os
import gc
@ -2399,11 +2400,13 @@ def get_transformed_credentials(account, profile=None):
# Apply profile-specific transformations if profile is provided
if profile and profile.search_pattern and profile.replace_pattern:
try:
# Handle backreferences in the replacement pattern
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', profile.replace_pattern)
# Handle backreferences: convert JS-style $<name> -> \g<name>, $1 -> \1
# regex module accepts JS-style (?<name>...) named groups natively
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', profile.replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
# Apply transformation to the complete URL
transformed_complete_url = re.sub(profile.search_pattern, safe_replace_pattern, complete_url)
transformed_complete_url = regex.sub(profile.search_pattern, safe_replace_pattern, complete_url)
logger.info(f"Transformed complete URL: {complete_url} -> {transformed_complete_url}")
# Extract components from the transformed URL

View file

@ -31,7 +31,7 @@ def fetch_channel_stats():
while True:
cursor, keys = redis_client.scan(cursor, match=channel_pattern)
for key in keys:
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key.decode('utf-8'))
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key)
if channel_id_match:
ch_id = channel_id_match.group(1)
channel_info = ChannelStatus.get_basic_channel_info(ch_id)

View file

@ -39,19 +39,19 @@ class ChannelStatus:
info = {
'channel_id': channel_id,
'state': metadata.get(ChannelMetadataField.STATE.encode('utf-8'), b'unknown').decode('utf-8'),
'url': metadata.get(ChannelMetadataField.URL.encode('utf-8'), b'').decode('utf-8'),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8'), b'').decode('utf-8'),
'started_at': metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0').decode('utf-8'),
'owner': metadata.get(ChannelMetadataField.OWNER.encode('utf-8'), b'unknown').decode('utf-8'),
'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0,
'state': metadata.get(ChannelMetadataField.STATE, 'unknown'),
'url': metadata.get(ChannelMetadataField.URL, ''),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ''),
'started_at': metadata.get(ChannelMetadataField.INIT_TIME, '0'),
'owner': metadata.get(ChannelMetadataField.OWNER, 'unknown'),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
}
# Add stream ID and name information
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID.encode('utf-8'))
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes.decode('utf-8'))
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
@ -66,10 +66,10 @@ class ChannelStatus:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
# Add M3U profile information
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE.encode('utf-8'))
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id_bytes:
try:
m3u_profile_id = int(m3u_profile_id_bytes.decode('utf-8'))
m3u_profile_id = int(m3u_profile_id_bytes)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
@ -84,22 +84,22 @@ class ChannelStatus:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id_bytes}")
# Add timing information
state_changed_field = ChannelMetadataField.STATE_CHANGED_AT.encode('utf-8')
state_changed_field = ChannelMetadataField.STATE_CHANGED_AT
if state_changed_field in metadata:
state_changed_at = float(metadata[state_changed_field].decode('utf-8'))
state_changed_at = float(metadata[state_changed_field])
info['state_changed_at'] = state_changed_at
info['state_duration'] = time.time() - state_changed_at
init_time_field = ChannelMetadataField.INIT_TIME.encode('utf-8')
init_time_field = ChannelMetadataField.INIT_TIME
if init_time_field in metadata:
created_at = float(metadata[init_time_field].decode('utf-8'))
created_at = float(metadata[init_time_field])
info['started_at'] = created_at
info['uptime'] = time.time() - created_at
# Add data throughput information
total_bytes_field = ChannelMetadataField.TOTAL_BYTES.encode('utf-8')
total_bytes_field = ChannelMetadataField.TOTAL_BYTES
if total_bytes_field in metadata:
total_bytes = int(metadata[total_bytes_field].decode('utf-8'))
total_bytes = int(metadata[total_bytes_field])
info['total_bytes'] = total_bytes
# Format total bytes in human-readable form
@ -130,7 +130,7 @@ class ChannelStatus:
stale_client_ids = []
for client_id in client_ids:
client_id_str = client_id.decode('utf-8')
client_id_str = client_id
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
client_data = proxy_server.redis_client.hgetall(client_key)
@ -141,33 +141,35 @@ class ChannelStatus:
client_info = {
'client_id': client_id_str,
'user_agent': client_data.get(b'user_agent', b'unknown').decode('utf-8'),
'worker_id': client_data.get(b'worker_id', b'unknown').decode('utf-8'),
'user_agent': client_data.get('user_agent', 'unknown'),
'worker_id': client_data.get('worker_id', 'unknown'),
'ip_address': client_data.get('ip_address', 'unknown'),
'user_id': client_data.get('user_id', '0'),
}
if b'connected_at' in client_data:
connected_at = float(client_data[b'connected_at'].decode('utf-8'))
if 'connected_at' in client_data:
connected_at = float(client_data['connected_at'])
client_info['connected_at'] = connected_at
client_info['connection_duration'] = time.time() - connected_at
if b'last_active' in client_data:
last_active = float(client_data[b'last_active'].decode('utf-8'))
if 'last_active' in client_data:
last_active = float(client_data['last_active'])
client_info['last_active'] = last_active
client_info['last_active_ago'] = time.time() - last_active
# Add transfer rate statistics
if b'bytes_sent' in client_data:
client_info['bytes_sent'] = int(client_data[b'bytes_sent'].decode('utf-8'))
if 'bytes_sent' in client_data:
client_info['bytes_sent'] = int(client_data['bytes_sent'])
# Add average transfer rate
if b'avg_rate_KBps' in client_data:
client_info['avg_rate_KBps'] = float(client_data[b'avg_rate_KBps'].decode('utf-8'))
elif b'transfer_rate_KBps' in client_data: # For backward compatibility
client_info['avg_rate_KBps'] = float(client_data[b'transfer_rate_KBps'].decode('utf-8'))
if 'avg_rate_KBps' in client_data:
client_info['avg_rate_KBps'] = float(client_data['avg_rate_KBps'])
elif 'transfer_rate_KBps' in client_data: # For backward compatibility
client_info['avg_rate_KBps'] = float(client_data['transfer_rate_KBps'])
# Add current transfer rate
if b'current_rate_KBps' in client_data:
client_info['current_rate_KBps'] = float(client_data[b'current_rate_KBps'].decode('utf-8'))
if 'current_rate_KBps' in client_data:
client_info['current_rate_KBps'] = float(client_data['current_rate_KBps'])
clients.append(client_info)
@ -249,7 +251,7 @@ class ChannelStatus:
while True:
cursor, keys = proxy_server.redis_client.scan(cursor, match=buffer_key_pattern, count=100)
if keys:
all_buffer_keys.extend([k.decode('utf-8') for k in keys])
all_buffer_keys.extend([k for k in keys])
if cursor == 0 or len(all_buffer_keys) >= 20: # Limit to 20 keys
break
@ -279,61 +281,64 @@ class ChannelStatus:
}
# Add FFmpeg stream information
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC.encode('utf-8'))
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC)
if video_codec:
info['video_codec'] = video_codec.decode('utf-8')
info['video_codec'] = video_codec
resolution = metadata.get(ChannelMetadataField.RESOLUTION.encode('utf-8'))
resolution = metadata.get(ChannelMetadataField.RESOLUTION)
if resolution:
info['resolution'] = resolution.decode('utf-8')
info['resolution'] = resolution
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS.encode('utf-8'))
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS)
if source_fps:
info['source_fps'] = float(source_fps.decode('utf-8'))
info['source_fps'] = source_fps
pixel_format = metadata.get(ChannelMetadataField.PIXEL_FORMAT.encode('utf-8'))
pixel_format = metadata.get(ChannelMetadataField.PIXEL_FORMAT)
if pixel_format:
info['pixel_format'] = pixel_format.decode('utf-8')
info['pixel_format'] = pixel_format
source_bitrate = metadata.get(ChannelMetadataField.SOURCE_BITRATE.encode('utf-8'))
source_bitrate = metadata.get(ChannelMetadataField.SOURCE_BITRATE)
if source_bitrate:
info['source_bitrate'] = float(source_bitrate.decode('utf-8'))
info['source_bitrate'] = source_bitrate
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC.encode('utf-8'))
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC)
if audio_codec:
info['audio_codec'] = audio_codec.decode('utf-8')
info['audio_codec'] = audio_codec
sample_rate = metadata.get(ChannelMetadataField.SAMPLE_RATE.encode('utf-8'))
sample_rate = metadata.get(ChannelMetadataField.SAMPLE_RATE)
if sample_rate:
info['sample_rate'] = int(sample_rate.decode('utf-8'))
info['sample_rate'] = sample_rate
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8'))
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS)
if audio_channels:
info['audio_channels'] = audio_channels.decode('utf-8')
info['audio_channels'] = audio_channels
audio_bitrate = metadata.get(ChannelMetadataField.AUDIO_BITRATE.encode('utf-8'))
audio_bitrate = metadata.get(ChannelMetadataField.AUDIO_BITRATE)
if audio_bitrate:
info['audio_bitrate'] = float(audio_bitrate.decode('utf-8'))
info['audio_bitrate'] = audio_bitrate
# Add FFmpeg performance stats
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED.encode('utf-8'))
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED)
if ffmpeg_speed:
info['ffmpeg_speed'] = float(ffmpeg_speed.decode('utf-8'))
info['ffmpeg_speed'] = ffmpeg_speed
ffmpeg_fps = metadata.get(ChannelMetadataField.FFMPEG_FPS.encode('utf-8'))
ffmpeg_fps = metadata.get(ChannelMetadataField.FFMPEG_FPS)
if ffmpeg_fps:
info['ffmpeg_fps'] = float(ffmpeg_fps.decode('utf-8'))
info['ffmpeg_fps'] = ffmpeg_fps
actual_fps = metadata.get(ChannelMetadataField.ACTUAL_FPS.encode('utf-8'))
actual_fps = metadata.get(ChannelMetadataField.ACTUAL_FPS)
if actual_fps:
info['actual_fps'] = float(actual_fps.decode('utf-8'))
info['actual_fps'] = actual_fps
ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE.encode('utf-8'))
ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE)
if ffmpeg_bitrate:
info['ffmpeg_bitrate'] = float(ffmpeg_bitrate.decode('utf-8'))
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8'))
info['ffmpeg_bitrate'] = ffmpeg_bitrate
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE)
if stream_type:
info['stream_type'] = stream_type.decode('utf-8')
info['stream_type'] = stream_type
return info
@ -378,33 +383,27 @@ class ChannelStatus:
client_count = proxy_server.redis_client.scard(client_set_key) or 0
# Calculate uptime
init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME.encode('utf-8'), b'0')
created_at = float(init_time_bytes.decode('utf-8'))
init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME, '0')
created_at = float(init_time_bytes)
uptime = time.time() - created_at if created_at > 0 else 0
# Safely decode bytes or use defaults
def safe_decode(bytes_value, default="unknown"):
if bytes_value is None:
return default
return bytes_value.decode('utf-8')
# Simplified info
info = {
'channel_id': channel_id,
'state': safe_decode(metadata.get(ChannelMetadataField.STATE.encode('utf-8'))),
'url': safe_decode(metadata.get(ChannelMetadataField.URL.encode('utf-8')), ""),
'stream_profile': safe_decode(metadata.get(ChannelMetadataField.STREAM_PROFILE.encode('utf-8')), ""),
'owner': safe_decode(metadata.get(ChannelMetadataField.OWNER.encode('utf-8'))),
'buffer_index': int(buffer_index_value.decode('utf-8')) if buffer_index_value else 0,
'state': metadata.get(ChannelMetadataField.STATE),
'url': metadata.get(ChannelMetadataField.URL, ""),
'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ""),
'owner': metadata.get(ChannelMetadataField.OWNER),
'buffer_index': int(buffer_index_value) if buffer_index_value else 0,
'client_count': client_count,
'uptime': uptime
}
# Add stream ID and name information
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID.encode('utf-8'))
stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
try:
stream_id = int(stream_id_bytes.decode('utf-8'))
stream_id = int(stream_id_bytes)
info['stream_id'] = stream_id
# Look up stream name from database
@ -419,9 +418,9 @@ class ChannelStatus:
logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}")
# Add data throughput information to basic info
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES.encode('utf-8'))
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES)
if total_bytes_bytes:
total_bytes = int(total_bytes_bytes.decode('utf-8'))
total_bytes = int(total_bytes_bytes)
info['total_bytes'] = total_bytes
# Calculate and add bitrate
@ -458,25 +457,28 @@ class ChannelStatus:
if client_id in stale_client_ids:
continue
client_id_str = client_id.decode('utf-8')
client_key = RedisKeys.client_metadata(channel_id, client_id_str)
client_key = RedisKeys.client_metadata(channel_id, client_id)
client_info = {
'client_id': client_id_str,
'client_id': client_id,
}
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
client_info['user_agent'] = safe_decode(user_agent_bytes)
client_info['user_agent'] = user_agent_bytes
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
if ip_address_bytes:
client_info['ip_address'] = safe_decode(ip_address_bytes)
client_info['ip_address'] = ip_address_bytes
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
if connected_at_bytes:
connected_at = float(connected_at_bytes.decode('utf-8'))
connected_at = float(connected_at_bytes)
client_info['connected_since'] = time.time() - connected_at
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
if user_id_bytes:
client_info['user_id'] = user_id_bytes
clients.append(client_info)
# Add clients to info
@ -484,10 +486,10 @@ class ChannelStatus:
info['client_count'] = client_count
# Add M3U profile information
m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE.encode('utf-8'))
if m3u_profile_id_bytes:
m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE)
if m3u_profile_id:
try:
m3u_profile_id = int(m3u_profile_id_bytes.decode('utf-8'))
m3u_profile_id = int(m3u_profile_id)
info['m3u_profile_id'] = m3u_profile_id
# Look up M3U profile name from database
@ -499,32 +501,36 @@ class ChannelStatus:
except (ImportError, DatabaseError) as e:
logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}")
except ValueError:
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id_bytes}")
logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}")
# Add stream info to basic info as well
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC.encode('utf-8'))
video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC)
if video_codec:
info['video_codec'] = video_codec.decode('utf-8')
info['video_codec'] = video_codec
resolution = metadata.get(ChannelMetadataField.RESOLUTION.encode('utf-8'))
resolution = metadata.get(ChannelMetadataField.RESOLUTION)
if resolution:
info['resolution'] = resolution.decode('utf-8')
info['resolution'] = resolution
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS.encode('utf-8'))
source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS)
if source_fps:
info['source_fps'] = float(source_fps.decode('utf-8'))
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED.encode('utf-8'))
info['source_fps'] = float(source_fps)
ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED)
if ffmpeg_speed:
info['ffmpeg_speed'] = float(ffmpeg_speed.decode('utf-8'))
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC.encode('utf-8'))
info['ffmpeg_speed'] = float(ffmpeg_speed)
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC)
if audio_codec:
info['audio_codec'] = audio_codec.decode('utf-8')
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8'))
info['audio_codec'] = audio_codec
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS)
if audio_channels:
info['audio_channels'] = audio_channels.decode('utf-8')
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8'))
info['audio_channels'] = audio_channels
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE)
if stream_type:
info['stream_type'] = stream_type.decode('utf-8')
info['stream_type'] = stream_type
return info
except Exception as e:

View file

@ -1,10 +1,8 @@
"""Client connection management for TS streams"""
import threading
import logging
import time
import json
import gevent
from typing import Set, Optional
from apps.proxy.config import TSConfig as Config
from redis.exceptions import ConnectionError, TimeoutError
@ -58,7 +56,8 @@ class ClientManager:
from django.conf import settings
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
redis_client = redis.Redis.from_url(redis_url, decode_responses=True)
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
redis_client = redis.Redis.from_url(redis_url, decode_responses=True, **ssl_params)
all_channels = []
cursor = 0
@ -129,7 +128,7 @@ class ClientManager:
# Check for stale activity using last_active field
last_active = self.redis_client.hget(client_key, "last_active")
if last_active:
last_active_time = float(last_active.decode('utf-8'))
last_active_time = float(last_active)
ghost_timeout = self.heartbeat_interval * getattr(Config, 'GHOST_CLIENT_MULTIPLIER', 5.0)
if current_time - last_active_time > ghost_timeout:
@ -229,7 +228,7 @@ class ClientManager:
except Exception as e:
logger.error(f"Error notifying owner of client activity: {e}")
def add_client(self, client_id, client_ip, user_agent=None):
def add_client(self, client_id, client_ip, user_agent=None, user=None):
"""Add a client with duplicate prevention"""
if client_id in self._registered_clients:
logger.debug(f"Client {client_id} already registered, skipping")
@ -247,7 +246,9 @@ class ClientManager:
"ip_address": client_ip,
"connected_at": current_time,
"last_active": current_time,
"worker_id": self.worker_id or "unknown"
"worker_id": self.worker_id or "unknown",
"user_id": str(user.id) if user is not None else "0",
# "user_level": user.user_level if user is not None else 100, # default to a high value since no user means the non-user specific M3U/HDHR
}
try:
@ -277,7 +278,8 @@ class ClientManager:
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time()
"timestamp": time.time(),
"username": user.username if user is not None else "unknown"
}
if user_agent:
@ -308,8 +310,6 @@ class ClientManager:
def remove_client(self, client_id):
"""Remove a client from this channel and Redis"""
client_ip = None
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
@ -320,13 +320,11 @@ class ClientManager:
self.last_active_time = time.time()
if self.redis_client:
# Get client IP before removing the data
# Get client data before removing the data
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
client_data = self.redis_client.hgetall(client_key)
if client_data and b'ip_address' in client_data:
client_ip = client_data[b'ip_address'].decode('utf-8')
elif client_data and 'ip_address' in client_data:
client_ip = client_data['ip_address']
client_username = self.redis_client.hget(client_key, "username") or "unknown"
if isinstance(client_username, bytes):
client_username = client_username.decode("utf-8")
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
@ -367,7 +365,8 @@ class ClientManager:
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining
"remaining_clients": remaining,
"username": client_username
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
@ -434,8 +433,7 @@ class ClientManager:
client_id_list = list(client_ids)
pipe = redis_client.pipeline()
for cid in client_id_list:
cid_str = cid.decode('utf-8')
pipe.exists(RedisKeys.client_metadata(channel_id, cid_str))
pipe.exists(RedisKeys.client_metadata(channel_id, cid))
results = pipe.execute()
stale_ids = [

View file

@ -166,6 +166,7 @@ class ProxyServer:
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
pubsub_client = redis.Redis(
host=redis_host,
port=redis_port,
@ -175,7 +176,9 @@ class ProxyServer:
socket_timeout=60,
socket_connect_timeout=10,
socket_keepalive=True,
health_check_interval=30
health_check_interval=30,
decode_responses=True,
**ssl_params
)
logger.info("Created fallback Redis PubSub client for event listener")
@ -196,8 +199,8 @@ class ProxyServer:
continue
try:
channel = message["channel"].decode("utf-8")
data = json.loads(message["data"].decode("utf-8"))
channel = message["channel"]
data = json.loads(message["data"])
event_type = data.get("event")
channel_id = data.get("channel_id")
@ -373,7 +376,7 @@ class ProxyServer:
if result is None:
return None
try:
return result.decode('utf-8')
return result
except (AttributeError, UnicodeDecodeError) as e:
logger.error(f"Error decoding channel owner for {channel_id}: {e}, raw={result!r}")
return None
@ -412,7 +415,7 @@ class ProxyServer:
current_owner = self._execute_redis_command(
lambda: self.redis_client.get(lock_key)
)
if current_owner and current_owner.decode('utf-8') == self.worker_id:
if current_owner and current_owner == self.worker_id:
# Refresh TTL
self._execute_redis_command(
lambda: self.redis_client.expire(lock_key, ttl)
@ -437,7 +440,7 @@ class ProxyServer:
# Only delete if we're the current owner to prevent race conditions
current = self.redis_client.get(lock_key)
if current and current.decode('utf-8') == self.worker_id:
if current and current == self.worker_id:
self.redis_client.delete(lock_key)
logger.info(f"Released ownership of channel {channel_id}")
@ -471,7 +474,7 @@ class ProxyServer:
return False
return False
if current.decode('utf-8') == self.worker_id:
if current == self.worker_id:
self.redis_client.expire(lock_key, ttl)
return True
@ -488,15 +491,15 @@ class ProxyServer:
metadata_key = RedisKeys.channel_metadata(channel_id)
if self.redis_client.exists(metadata_key):
metadata = self.redis_client.hgetall(metadata_key)
if b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if 'state' in metadata:
state = metadata['state']
active_states = [ChannelState.INITIALIZING, ChannelState.CONNECTING,
ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE, ChannelState.BUFFERING]
if state in active_states:
logger.info(f"Channel {channel_id} already being initialized with state {state}")
# Create buffer and client manager only if we don't have them
if channel_id not in self.stream_buffers:
self.stream_buffers[channel_id] = StreamBuffer(channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = StreamBuffer(channel_id, redis_client=RedisClient.get_buffer())
if channel_id not in self.client_managers:
self.client_managers[channel_id] = ClientManager(
channel_id,
@ -507,7 +510,7 @@ class ProxyServer:
# Create buffer and client manager instances (or reuse if they exist)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id, redis_client=RedisClient.get_buffer())
self.stream_buffers[channel_id] = buffer
if channel_id not in self.client_managers:
@ -546,18 +549,18 @@ class ProxyServer:
# If no url was passed, try to get from Redis
if not url and existing_metadata:
url_bytes = existing_metadata.get(b'url')
url_bytes = existing_metadata.get('url')
if url_bytes:
channel_url = url_bytes.decode('utf-8')
channel_url = url_bytes
ua_bytes = existing_metadata.get(b'user_agent')
ua_bytes = existing_metadata.get('user_agent')
if ua_bytes:
channel_user_agent = ua_bytes.decode('utf-8')
channel_user_agent = ua_bytes
# Get stream ID from metadata if not provided
if not channel_stream_id and b'stream_id' in existing_metadata:
if not channel_stream_id and 'stream_id' in existing_metadata:
try:
channel_stream_id = int(existing_metadata[b'stream_id'].decode('utf-8'))
channel_stream_id = int(existing_metadata['stream_id'])
logger.debug(f"Found stream_id {channel_stream_id} in metadata for channel {channel_id}")
except (ValueError, TypeError) as e:
logger.debug(f"Could not parse stream_id from metadata: {e}")
@ -572,7 +575,7 @@ class ProxyServer:
# Create buffer but not stream manager (only if not already exists)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer())
self.stream_buffers[channel_id] = buffer
# Create client manager with channel_id and redis_client (only if not already exists)
@ -595,7 +598,7 @@ class ProxyServer:
# Create buffer but not stream manager (only if not already exists)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer())
self.stream_buffers[channel_id] = buffer
# Create client manager with channel_id and redis_client (only if not already exists)
@ -634,12 +637,12 @@ class ProxyServer:
# Verify the stream_id was set correctly in Redis
stream_id_value = self.redis_client.hget(metadata_key, "stream_id")
if stream_id_value:
logger.info(f"Verified stream_id {stream_id_value.decode('utf-8')} is set in Redis for channel {channel_id}")
logger.info(f"Verified stream_id {stream_id_value} is set in Redis for channel {channel_id}")
else:
logger.warning(f"Failed to set stream_id in Redis for channel {channel_id}")
# Create stream buffer
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer())
logger.debug(f"Created StreamBuffer for channel {channel_id}")
self.stream_buffers[channel_id] = buffer
@ -733,8 +736,8 @@ class ProxyServer:
metadata = self.redis_client.hgetall(metadata_key)
# Get channel state and owner
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'').decode('utf-8')
state = metadata.get('state', 'unknown')
owner = metadata.get('owner', '')
# States that indicate the channel is running properly or shutting down
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS,
@ -772,8 +775,8 @@ class ProxyServer:
return False
else:
# Unknown or initializing state, check how long it's been in this state
if b'state_changed_at' in metadata:
state_changed_at = float(metadata[b'state_changed_at'].decode('utf-8'))
if 'state_changed_at' in metadata:
state_changed_at = float(metadata['state_changed_at'])
state_age = time.time() - state_changed_at
# If in initializing state for too long, consider it stale
@ -811,8 +814,8 @@ class ProxyServer:
# If we have metadata, log details for debugging
if metadata:
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'unknown').decode('utf-8')
state = metadata.get('state', 'unknown')
owner = metadata.get('owner', 'unknown')
logger.info(f"Zombie channel details - state: {state}, owner: {owner}")
# Clean up Redis keys
@ -937,16 +940,16 @@ class ProxyServer:
metadata = self.redis_client.hgetall(metadata_key)
if metadata:
# Calculate runtime from init_time
if b'init_time' in metadata:
if 'init_time' in metadata:
try:
init_time = float(metadata[b'init_time'].decode('utf-8'))
init_time = float(metadata['init_time'])
runtime = round(time.time() - init_time, 2)
except Exception:
pass
# Get total bytes transferred
if b'total_bytes' in metadata:
if 'total_bytes' in metadata:
try:
total_bytes = int(metadata[b'total_bytes'].decode('utf-8'))
total_bytes = int(metadata['total_bytes'])
except Exception:
pass
@ -1057,8 +1060,8 @@ class ProxyServer:
if self.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = self.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
channel_state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
channel_state = metadata['state']
# Check if channel has any clients left
total_clients = 0
@ -1090,9 +1093,9 @@ class ProxyServer:
# Get connection_ready_time from metadata (indicates if channel reached ready state)
connection_ready_time = None
if metadata and b'connection_ready_time' in metadata:
if metadata and 'connection_ready_time' in metadata:
try:
connection_ready_time = float(metadata[b'connection_ready_time'].decode('utf-8'))
connection_ready_time = float(metadata['connection_ready_time'])
except (ValueError, TypeError):
pass
@ -1104,15 +1107,15 @@ class ProxyServer:
attempt_value = self.redis_client.get(attempt_key)
if attempt_value:
try:
connection_attempt_time = float(attempt_value.decode('utf-8'))
connection_attempt_time = float(attempt_value)
except (ValueError, TypeError):
pass
# Also get init time as a fallback
init_time = None
if metadata and b'init_time' in metadata:
if metadata and 'init_time' in metadata:
try:
init_time = float(metadata[b'init_time'].decode('utf-8'))
init_time = float(metadata['init_time'])
except (ValueError, TypeError):
pass
@ -1183,7 +1186,7 @@ class ProxyServer:
disconnect_value = self.redis_client.get(disconnect_key)
if disconnect_value:
try:
disconnect_time = float(disconnect_value.decode('utf-8'))
disconnect_time = float(disconnect_value)
except (ValueError, TypeError) as e:
logger.error(f"Invalid disconnect time for channel {channel_id}: {e}")
@ -1304,7 +1307,7 @@ class ProxyServer:
for key in channel_keys:
try:
channel_id = key.decode('utf-8').split(':')[2]
channel_id = key.split(':')[2]
# Check if this channel has an owner
owner = self.get_channel_owner(channel_id)
@ -1349,7 +1352,7 @@ class ProxyServer:
for key in channel_keys:
try:
channel_id = key.decode('utf-8').split(':')[2]
channel_id = key.split(':')[2]
# Get metadata first
metadata = self.redis_client.hgetall(key)
@ -1364,7 +1367,7 @@ class ProxyServer:
continue
# Get owner
owner = metadata.get(b'owner', b'').decode('utf-8') if b'owner' in metadata else ''
owner = metadata.get('owner', '') if 'owner' in metadata else ''
# Check if owner is still alive
owner_alive = False
@ -1378,7 +1381,7 @@ class ProxyServer:
# If no owner and no clients, clean it up
if not owner_alive and client_count == 0:
state = metadata.get(b'state', b'unknown').decode('utf-8') if b'state' in metadata else 'unknown'
state = metadata.get('state', 'unknown')
logger.warning(f"Found orphaned metadata for channel {channel_id} (state: {state}, owner: {owner}, clients: {client_count}) - cleaning up")
# If we have it locally, stop it properly to clean up transcode/proxy processes
@ -1397,7 +1400,7 @@ class ProxyServer:
real_count = max(0, client_count - len(stale_ids))
if real_count <= 0:
# No real clients remain — safe to clean up.
state = metadata.get(b'state', b'unknown').decode('utf-8') if b'state' in metadata else 'unknown'
state = metadata.get('state', 'unknown')
logger.warning(
f"Orphaned channel {channel_id} (state: {state}, "
f"owner: {owner}) had {client_count} ghost client(s) "
@ -1492,8 +1495,8 @@ class ProxyServer:
# Get current state for logging
current_state = None
metadata = self.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
current_state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
current_state = metadata['state']
# Only update if state is actually changing
if current_state == new_state:

View file

@ -61,7 +61,7 @@ class ChannelService:
# Verify the stream_id was set
stream_id_value = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_value:
logger.debug(f"Verified stream_id {stream_id_value.decode('utf-8')} is now set in Redis")
logger.debug(f"Verified stream_id {stream_id_value} is now set in Redis")
else:
logger.error(f"Failed to set stream_id {stream_id} in Redis before initialization")
@ -131,7 +131,7 @@ class ChannelService:
try:
# This is inefficient but used for diagnostics - in production would use more targeted checks
redis_keys = proxy_server.redis_client.keys(f"ts_proxy:*:{channel_id}*")
redis_keys = [k.decode('utf-8') for k in redis_keys] if redis_keys else []
redis_keys = [k for k in redis_keys] if redis_keys else []
except Exception as e:
logger.error(f"Error checking Redis keys: {e}")
@ -236,8 +236,8 @@ class ChannelService:
metadata_key = RedisKeys.channel_metadata(channel_id)
try:
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
state = metadata['state']
channel_info = {"state": state}
# Immediately mark as stopping in metadata so clients detect it faster
@ -382,8 +382,8 @@ class ChannelService:
metadata = proxy_server.redis_client.hgetall(metadata_key)
# Extract state and owner
state = metadata.get(ChannelMetadataField.STATE.encode(), b'unknown').decode('utf-8')
owner = metadata.get(ChannelMetadataField.OWNER.encode(), b'unknown').decode('utf-8')
state = metadata.get(ChannelMetadataField.STATE, 'unknown')
owner = metadata.get(ChannelMetadataField.OWNER, 'unknown')
# Valid states indicate channel is running properly
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING]
@ -409,7 +409,7 @@ class ChannelService:
}
if last_data:
last_data_time = float(last_data.decode('utf-8'))
last_data_time = float(last_data)
data_age = time.time() - last_data_time
details["last_data_age"] = data_age
@ -432,13 +432,13 @@ class ChannelService:
try:
# Use factory to parse the line based on stream type
parsed_data = LogParserFactory.parse(stream_type, stream_info_line)
if not parsed_data:
return
# Update Redis and database with parsed data
ChannelService._update_stream_info_in_redis(
channel_id,
channel_id,
parsed_data.get('video_codec'),
parsed_data.get('resolution'),
parsed_data.get('width'),
@ -579,7 +579,7 @@ class ChannelService:
metadata_key = RedisKeys.channel_metadata(channel_id)
# First check if the key exists and what type it is
key_type = proxy_server.redis_client.type(metadata_key).decode('utf-8')
key_type = proxy_server.redis_client.type(metadata_key)
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# Build metadata update dict

View file

@ -25,7 +25,7 @@ class StreamGenerator:
data delivery, and cleanup.
"""
def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None):
"""
Initialize the stream generator with client and channel details.
@ -35,12 +35,14 @@ class StreamGenerator:
client_ip: Client's IP address
client_user_agent: User agent string from client
channel_initializing: Whether the channel is still initializing
user: Authenticated user making the request
"""
self.channel_id = channel_id
self.client_id = client_id
self.client_ip = client_ip
self.client_user_agent = client_user_agent
self.channel_initializing = channel_initializing
self.user = user
# Performance and state tracking
self.stream_start_time = time.time()
@ -112,7 +114,8 @@ class StreamGenerator:
channel_name=channel_obj.name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client connect event: {e}")
@ -141,13 +144,13 @@ class StreamGenerator:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
state = metadata['state']
if state in ['waiting_for_clients', 'active']:
logger.info(f"[{self.client_id}] Channel {self.channel_id} now ready (state={state})")
return True
elif state in ['error', 'stopped', 'stopping']: # Added 'stopping' to error states
error_message = metadata.get(b'error_message', b'Unknown error').decode('utf-8')
error_message = metadata.get('error_message', 'Unknown error')
logger.error(f"[{self.client_id}] Channel {self.channel_id} in error state: {state}, message: {error_message}")
# Send error packet before giving up
yield create_ts_packet('error', f"Error: {error_message}")
@ -155,9 +158,9 @@ class StreamGenerator:
else:
# Improved logging to track initialization progress
init_time = "unknown"
if b'init_time' in metadata:
if 'init_time' in metadata:
try:
init_time_float = float(metadata[b'init_time'].decode('utf-8'))
init_time_float = float(metadata['init_time'])
init_duration = time.time() - init_time_float
init_time = f"{init_duration:.1f}s ago"
except:
@ -390,8 +393,8 @@ class StreamGenerator:
# Channel state in metadata
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and b'state' in metadata:
state = metadata[b'state'].decode('utf-8')
if metadata and 'state' in metadata:
state = metadata['state']
if state in ['error', 'stopped', 'stopping']:
logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream")
return False
@ -555,8 +558,6 @@ class StreamGenerator:
if metadata:
stream_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
stream_id = int(stream_id_bytes.decode('utf-8'))
# Check if we're the last client
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
@ -595,7 +596,8 @@ class StreamGenerator:
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
duration=round(elapsed, 2),
bytes_sent=self.bytes_sent
bytes_sent=self.bytes_sent,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
@ -630,10 +632,10 @@ class StreamGenerator:
gevent.spawn(delayed_shutdown)
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False):
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None):
"""
Factory function to create a new stream generator.
Returns a function that can be passed to StreamingHttpResponse.
"""
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing)
generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user)
return generator.generate

View file

@ -90,7 +90,7 @@ class StreamManager:
# Try to get stream_id specifically
stream_id_bytes = buffer.redis_client.hget(metadata_key, "stream_id")
if stream_id_bytes:
self.current_stream_id = int(stream_id_bytes.decode('utf-8'))
self.current_stream_id = int(stream_id_bytes)
self.tried_stream_ids.add(self.current_stream_id)
logger.info(f"Loaded stream ID {self.current_stream_id} from Redis for channel {buffer.channel_id}")
else:
@ -413,7 +413,7 @@ class StreamManager:
is_owner = (
current_owner
and self.worker_id
and current_owner.decode('utf-8') == self.worker_id
and current_owner == self.worker_id
)
no_owner = current_owner is None
@ -423,7 +423,7 @@ class StreamManager:
metadata_key, ChannelMetadataField.STATE
)
current_state = (
current_state_bytes.decode('utf-8')
current_state_bytes
if current_state_bytes else None
)
should_update = current_state in ChannelState.PRE_ACTIVE
@ -1503,9 +1503,9 @@ class StreamManager:
current_state = None
try:
metadata = redis_client.hgetall(metadata_key)
state_field = ChannelMetadataField.STATE.encode('utf-8')
state_field = ChannelMetadataField.STATE
if metadata and state_field in metadata:
current_state = metadata[state_field].decode('utf-8')
current_state = metadata[state_field]
except Exception as e:
logger.error(f"Error checking current state: {e}")
@ -1710,4 +1710,4 @@ class StreamManager:
"""Safely reset the URL switching state if it gets stuck"""
self.url_switching = False
self.url_switch_start_time = 0
logger.info(f"Reset URL switching state for channel {self.channel_id}")
logger.info(f"Reset URL switching state for channel {self.channel_id}")

View file

@ -3,7 +3,7 @@ Utilities for handling stream URLs and transformations.
"""
import logging
import re
import regex
from typing import Optional, Tuple, List
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel, Stream
@ -146,13 +146,14 @@ def transform_url(input_url: str, search_pattern: str, replace_pattern: str) ->
logger.debug(f" base URL: {input_url}")
logger.debug(f" search: {search_pattern}")
# Handle backreferences in the replacement pattern
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace_pattern)
# Convert JS-style backreferences in replace pattern: $<name> -> \g<name>, $1 -> \1
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
logger.debug(f" replace: {replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
# Apply the transformation
stream_url = re.sub(search_pattern, safe_replace_pattern, input_url)
# Apply the transformation (regex module accepts JS-style (?<name>...) natively)
stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url)
logger.info(f"Generated stream url: {stream_url}")
return stream_url
@ -211,9 +212,9 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id.decode('utf-8')
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
if existing_profile_id and int(existing_profile_id.decode('utf-8')) == profile.id:
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")
@ -349,9 +350,9 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id.decode('utf-8')
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
if existing_profile_id and int(existing_profile_id.decode('utf-8')) == profile.id:
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")

View file

@ -83,7 +83,7 @@ def create_ts_packet(packet_type='null', message=None):
# Add message to payload if provided
if message:
msg_bytes = message.encode('utf-8')
msg_bytes = message
packet[4:4+min(len(msg_bytes), 180)] = msg_bytes[:180]
return bytes(packet)
@ -113,4 +113,4 @@ def get_logger(component_name=None):
# Default if detection fails
logger_name = "ts_proxy"
return logging.getLogger(logger_name)
return logging.getLogger(logger_name)

View file

@ -40,16 +40,20 @@ from .utils import get_logger
from uuid import UUID
import gevent
from dispatcharr.utils import network_access_allowed
from apps.proxy.utils import check_user_stream_limits
logger = get_logger()
@api_view(["GET"])
def stream_ts(request, channel_id):
def stream_ts(request, channel_id, user=None):
if not network_access_allowed(request, "STREAMS"):
return JsonResponse({"error": "Forbidden"}, status=403)
"""Stream TS data to client with immediate response and keep-alive packets during initialization"""
if user is None and hasattr(request, 'user') and request.user.is_authenticated:
user = request.user
channel = get_stream_object(channel_id)
client_user_agent = None
@ -71,6 +75,13 @@ def stream_ts(request, channel_id):
)
break
if user:
if not check_user_stream_limits(user, client_id, media_id=channel_id):
return JsonResponse(
{"error": f"Stream limit exceeded ({user.stream_limit} concurrent streams allowed)"},
status=429
)
# Check if we need to reinitialize the channel
needs_initialization = True
channel_state = None
@ -81,9 +92,9 @@ def stream_ts(request, channel_id):
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
metadata = proxy_server.redis_client.hgetall(metadata_key)
state_field = ChannelMetadataField.STATE.encode("utf-8")
state_field = ChannelMetadataField.STATE
if state_field in metadata:
channel_state = metadata[state_field].decode("utf-8")
channel_state = metadata[state_field]
# Active/running states - channel is operational, don't reinitialize
if channel_state in [
@ -119,9 +130,9 @@ def stream_ts(request, channel_id):
)
# Unknown/empty state - check if owner is alive
else:
owner_field = ChannelMetadataField.OWNER.encode("utf-8")
owner_field = ChannelMetadataField.OWNER
if owner_field in metadata:
owner = metadata[owner_field].decode("utf-8")
owner = metadata[owner_field]
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
if proxy_server.redis_client.exists(owner_heartbeat_key):
# Owner is still active with unknown state - don't reinitialize
@ -399,7 +410,7 @@ def stream_ts(request, channel_id):
metadata_key, ChannelMetadataField.STATE
)
if state_bytes:
current_state = state_bytes.decode("utf-8")
current_state = state_bytes
logger.debug(
f"[{client_id}] Current state of channel {channel_id}: {current_state}"
)
@ -475,12 +486,12 @@ def stream_ts(request, channel_id):
)
if url_bytes:
url = url_bytes.decode("utf-8")
url = url_bytes
if ua_bytes:
stream_user_agent = ua_bytes.decode("utf-8")
stream_user_agent = ua_bytes
# Extract transcode setting from Redis
if profile_bytes:
profile_str = profile_bytes.decode("utf-8")
profile_str = profile_bytes
use_transcode = (
profile_str == PROXY_PROFILE_NAME or profile_str == "None"
)
@ -516,12 +527,12 @@ def stream_ts(request, channel_id):
# Register client
buffer = proxy_server.stream_buffers[channel_id]
client_manager = proxy_server.client_managers[channel_id]
client_manager.add_client(client_id, client_ip, client_user_agent)
client_manager.add_client(client_id, client_ip, client_user_agent, user)
logger.info(f"[{client_id}] Client registered with channel {channel_id}")
# Create a stream generator for this client
generate = create_stream_generator(
channel_id, client_id, client_ip, client_user_agent, channel_initializing
channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user
)
# Return the StreamingHttpResponse from the main function
@ -557,7 +568,6 @@ def stream_xc(request, username, password, channel_id):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
print(f"Fetchin channel with ID: {channel_id}")
if user.user_level < 10:
user_profile_count = user.channel_profiles.count()
@ -585,7 +595,7 @@ def stream_xc(request, username, password, channel_id):
channel = get_object_or_404(Channel, id=channel_id)
# @TODO: we've got the file 'type' via extension, support this when we support multiple outputs
return stream_ts(request._request, str(channel.uuid))
return stream_ts(request._request, str(channel.uuid), user)
@csrf_exempt
@ -713,7 +723,7 @@ def channel_status(request, channel_id=None):
)
for key in keys:
channel_id_match = re.search(
r"ts_proxy:channel:(.*):metadata", key.decode("utf-8")
r"ts_proxy:channel:(.*):metadata", key
)
if channel_id_match:
ch_id = channel_id_match.group(1)
@ -834,7 +844,7 @@ def next_stream(request, channel_id):
metadata_key, ChannelMetadataField.STREAM_ID
)
if stream_id_bytes:
current_stream_id = int(stream_id_bytes.decode("utf-8"))
current_stream_id = int(stream_id_bytes)
logger.info(
f"Found current stream ID {current_stream_id} in Redis for channel {channel_id}"
)
@ -844,7 +854,7 @@ def next_stream(request, channel_id):
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if profile_id_bytes:
profile_id = int(profile_id_bytes.decode("utf-8"))
profile_id = int(profile_id_bytes)
logger.info(
f"Found M3U profile ID {profile_id} in Redis for channel {channel_id}"
)

View file

@ -6,4 +6,4 @@ urlpatterns = [
path('ts/', include('apps.proxy.ts_proxy.urls')),
path('hls/', include('apps.proxy.hls_proxy.urls')),
path('vod/', include('apps.proxy.vod_proxy.urls')),
]
]

185
apps/proxy/utils.py Normal file
View file

@ -0,0 +1,185 @@
import logging
from core.utils import RedisClient
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, get_vod_client_stop_key
from core.models import CoreSettings
from apps.proxy.ts_proxy.services.channel_service import ChannelService
logger = logging.getLogger("proxy")
def attempt_stream_termination(user_id, requesting_client_id, active_connections):
try:
logger.info("[stream limits]" f"[{requesting_client_id}] User {user_id} has {len(active_connections)} active connections, checking termination candidates")
user_limit_settings = CoreSettings.get_user_limits_settings()
terminate_oldest = user_limit_settings.get("terminate_oldest", True)
prioritize_single = user_limit_settings.get("prioritize_single_client_channels", True)
ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False)
channel_counts = {}
for connection in active_connections:
media_id = connection['media_id']
channel_counts[media_id] = channel_counts.get(media_id, 0) + 1
def prioritize(connection):
is_multi = channel_counts[connection['media_id']] > 1
# if we're ignoring same-channel connections, put them at the end
same_ch_key = 1 if (ignore_same_channel and is_multi) else 0
# key for prioritizing single-client channels
single_key = 0 if (prioritize_single and not is_multi) else 1
# sort by age setting
time_key = connection['connected_at'] if terminate_oldest else -connection['connected_at']
return (same_ch_key, single_key, time_key)
termination_candidates = sorted(active_connections, key=prioritize)
if not termination_candidates:
logger.warning("[stream limits]" f"[{requesting_client_id}] No termination candidates found for user {user_id}")
return False
target = termination_candidates[0]
logger.info("[stream limits]"
f"[{requesting_client_id}] Terminating client {target['client_id']} "
f"on media {target['media_id']} (connected_at={target['connected_at']})"
)
# When counting by unique channel, freeing one connection from a multi-connection
# channel doesn't free a slot — terminate all connections to that channel so the
# unique-channel count actually drops by one.
targets = (
[c for c in active_connections if c['media_id'] == target['media_id']]
if ignore_same_channel
else [target]
)
for t in targets:
if t['type'] == 'live':
result = ChannelService.stop_client(t['media_id'], t['client_id'])
if result.get("status") == "error":
logger.warning(f"[stream limits][{requesting_client_id}] Failed to stop client {t['client_id']} on channel {t['media_id']}")
else:
connection_manager = MultiWorkerVODConnectionManager.get_instance()
redis_client = connection_manager.redis_client
if not redis_client:
return False
connection_key = f"vod_persistent_connection:{t['client_id']}"
connection_data = redis_client.hgetall(connection_key)
if not connection_data:
logger.warning(f"VOD connection not found: {t['client_id']}")
continue
stop_key = get_vod_client_stop_key(t['client_id'])
redis_client.setex(stop_key, 60, "true") # 60 second TTL
return True
except Exception as e:
logger.error("[stream limits]" f"[{requesting_client_id}] Error during stream termination for user {user_id}: {e}")
return False
def get_user_active_connections(user_id):
redis_client = RedisClient.get_client()
connections = []
try:
# Grab live streams
for key in redis_client.scan_iter(match="ts_proxy:channel:*:clients:*", count=1000):
parts = key.split(':')
if len(parts) >= 5:
channel_id = parts[2]
client_id = parts[4]
client_user_id = redis_client.hget(key, 'user_id')
connected_at = redis_client.hget(key, 'connected_at')
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] channel_id = {channel_id}")
logger.debug(f"[stream limits] client_id = {client_id}")
if client_user_id and int(client_user_id) == user_id:
try:
logger.debug(f"[stream limits] Found LIVE connection for user {user_id} on channel {channel_id} with client ID {client_id}")
connected_at = float(connected_at) if connected_at else 0
connections.append({
'media_id': channel_id,
'client_id': client_id,
'connected_at': connected_at,
'type': 'live',
})
except (ValueError, TypeError):
pass
# Grab VOD
for key in redis_client.scan_iter(match="vod_persistent_connection:*", count=1000):
parts = key.split(':')
if len(parts) >= 2:
client_id = parts[1]
client_user_id = redis_client.hget(key, 'user_id')
connected_at = redis_client.hget(key, 'created_at')
content_uuid = redis_client.hget(key, 'content_uuid')
logger.debug(f"[stream limits] user_id = {user_id}")
logger.debug(f"[stream limits] client_id = {client_id}")
if client_user_id and int(client_user_id) == user_id:
try:
logger.debug(f"[stream limits] Found VOD connection for user {user_id} on content {content_uuid} with client ID {client_id}")
connected_at = float(connected_at) if connected_at else 0
connections.append({
'media_id': content_uuid or client_id,
'client_id': client_id,
'connected_at': connected_at,
'type': 'vod',
})
except (ValueError, TypeError):
pass
return connections
except Exception as e:
logger.warning(f"Error getting active channel details for user {user_id}: {e}")
return []
def check_user_stream_limits(user, client_id, media_id=None):
# Check user stream limits
if user and user.stream_limit > 0:
logger.debug("[stream limits]" f"[{client_id}] User {user.username} (ID: {user.id}) is requesting a stream (stream_limit: {user.stream_limit})")
user_limit_settings = CoreSettings.get_user_limits_settings()
ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False)
active_connections = get_user_active_connections(user.id)
unique_channel_count = set([conn['media_id'] for conn in active_connections])
user_stream_count = len(unique_channel_count) if ignore_same_channel else len(active_connections)
logger.debug(f"[stream limits]" f"[{client_id}] User {user.username} currently has {len(active_connections)} active connections across {len(unique_channel_count)} unique channels (counting method: {'unique channels' if ignore_same_channel else 'total connections'})")
# If ignore_same_channel is enabled and this request is for a live channel the user
# is already watching, allow it through without counting against the limit.
# VOD is excluded: connections aren't shared so multiple VOD connections to the
# same content would mean multiple upstream connections.
live_channel_ids = {str(conn['media_id']) for conn in active_connections if conn['type'] == 'live'}
if ignore_same_channel and media_id and str(media_id) in live_channel_ids:
logger.debug(f"[stream limits][{client_id}] Same-channel reconnect for {media_id} allowed (ignore_same_channel=True)")
return True
if user_stream_count >= user.stream_limit:
if user_limit_settings.get("terminate_on_limit_exceeded", True) == False:
return False
if user_stream_count >= user.stream_limit:
logger.warning("[stream limits]"
f"[{client_id}] User {user.username} (ID: {user.id}) has reached stream limit "
f"({user_stream_count}/{user.stream_limit} streams), attempting to free up slot"
)
if not attempt_stream_termination(user.id, client_id, active_connections):
return False
return True

View file

@ -103,13 +103,15 @@ class PersistentVODConnection:
redis_db = int(getattr(settings, 'REDIS_DB', 0))
redis_password = getattr(settings, 'REDIS_PASSWORD', '')
redis_user = getattr(settings, 'REDIS_USER', '')
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
r = redis.StrictRedis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
decode_responses=True
decode_responses=True,
**ssl_params
)
content_length_key = f"vod_content_length:{self.session_id}"
stored_length = r.get(content_length_key)
@ -342,15 +344,15 @@ class VODConnectionManager:
continue
# Extract session info
stored_content_type = session_data.get(b'content_type', b'').decode('utf-8')
stored_content_uuid = session_data.get(b'content_uuid', b'').decode('utf-8')
stored_content_type = session_data.get('content_type', '')
stored_content_uuid = session_data.get('content_uuid', '')
# Check if content matches
if stored_content_type != content_type or stored_content_uuid != content_uuid:
continue
# Extract session ID from key
session_id = key.decode('utf-8').replace('vod_session:', '')
session_id = key.replace('vod_session:', '')
# Check if session has an active persistent connection
persistent_conn = self._persistent_connections.get(session_id)
@ -364,13 +366,13 @@ class VODConnectionManager:
continue
# Get stored client info for comparison
stored_client_ip = session_data.get(b'client_ip', b'').decode('utf-8')
stored_user_agent = session_data.get(b'user_agent', b'').decode('utf-8')
stored_client_ip = session_data.get('client_ip', '')
stored_user_agent = session_data.get('user_agent', '')
# Check timeshift parameters match
stored_utc_start = session_data.get(b'utc_start', b'').decode('utf-8')
stored_utc_end = session_data.get(b'utc_end', b'').decode('utf-8')
stored_offset = session_data.get(b'offset', b'').decode('utf-8')
stored_utc_start = session_data.get('utc_start', '')
stored_utc_end = session_data.get('utc_end', '')
stored_offset = session_data.get('offset', '')
current_utc_start = utc_start or ""
current_utc_end = utc_end or ""
@ -407,7 +409,7 @@ class VODConnectionManager:
'session_id': session_id,
'score': score,
'reasons': match_reasons,
'last_activity': float(session_data.get(b'last_activity', b'0').decode('utf-8'))
'last_activity': float(session_data.get('last_activity', '0'))
})
except Exception as e:
@ -588,7 +590,7 @@ class VODConnectionManager:
# Get current bytes and add to it
current_bytes = self.redis_client.hget(connection_key, "bytes_sent")
if current_bytes:
total_bytes = int(current_bytes.decode('utf-8')) + bytes_sent
total_bytes = int(current_bytes) + bytes_sent
else:
total_bytes = bytes_sent
update_data["bytes_sent"] = str(total_bytes)
@ -623,7 +625,7 @@ class VODConnectionManager:
profile_id = None
if b"m3u_profile_id" in connection_data:
try:
profile_id = int(connection_data[b"m3u_profile_id"].decode('utf-8'))
profile_id = int(connection_data["m3u_profile_id"])
except ValueError:
pass
@ -669,16 +671,14 @@ class VODConnectionManager:
# Convert bytes to strings and parse numbers
info = {}
for key, value in connection_data.items():
key_str = key.decode('utf-8')
value_str = value.decode('utf-8')
# Parse numeric fields
if key_str in ['connected_at', 'last_activity']:
info[key_str] = float(value_str)
elif key_str in ['bytes_sent', 'position_seconds', 'm3u_profile_id']:
info[key_str] = int(value_str)
if key in ['connected_at', 'last_activity']:
info[key] = float(value)
elif key in ['bytes_sent', 'position_seconds', 'm3u_profile_id']:
info[key] = int(valuvaluee_str)
else:
info[key_str] = value_str
info[key] = value
return info
@ -728,14 +728,13 @@ class VODConnectionManager:
for key in keys:
try:
key_str = key.decode('utf-8')
last_activity = self.redis_client.hget(key, "last_activity")
if last_activity:
last_activity_time = float(last_activity.decode('utf-8'))
last_activity_time = float(last_activity)
if current_time - last_activity_time > max_age_seconds:
# Extract info for cleanup
parts = key_str.split(':')
parts = key.split(':')
if len(parts) >= 5:
content_type = parts[2]
content_uuid = parts[3]
@ -1009,7 +1008,7 @@ class VODConnectionManager:
return HttpResponse(f"Streaming error: {str(e)}", status=500)
def stream_content_with_session(self, session_id, content_obj, stream_url, m3u_profile, client_ip, user_agent, request,
utc_start=None, utc_end=None, offset=None, range_header=None):
utc_start=None, utc_end=None, offset=None, range_header=None, user=None):
"""
Stream VOD content with persistent connection per session
@ -1394,9 +1393,9 @@ class VODConnectionManager:
session_data = self.redis_client.hgetall(session_key)
if session_data:
# Get session details for connection cleanup
content_type = session_data.get(b'content_type', b'').decode('utf-8')
content_uuid = session_data.get(b'content_uuid', b'').decode('utf-8')
profile_id = session_data.get(b'profile_id')
content_type = session_data.get('content_type', '')
content_uuid = session_data.get('content_uuid', '')
profile_id = session_data.get('profile_id')
# Generate client_id from session_id (matches what's used during streaming)
client_id = session_id
@ -1415,12 +1414,12 @@ class VODConnectionManager:
# Fallback DECR: only if the connection tracking key had already
# expired (remove_connection skips DECR in that case)
if not connection_key_exists:
if session_data.get(b'connection_counted') == b'True' and profile_id:
profile_key = self._get_profile_connections_key(int(profile_id.decode('utf-8')))
if session_data.get('connection_counted') == 'True' and profile_id:
profile_key = self._get_profile_connections_key(int(profile_id))
current_count = int(self.redis_client.get(profile_key) or 0)
if current_count > 0:
self.redis_client.decr(profile_key)
logger.info(f"[{session_id}] Decremented profile {profile_id.decode('utf-8')} connections (fallback)")
logger.info(f"[{session_id}] Decremented profile {profile_id} connections (fallback)")
# Remove session tracking key
self.redis_client.delete(session_key)
@ -1440,7 +1439,7 @@ class VODConnectionManager:
if session_related_keys:
# Filter out keys we already deleted
remaining_keys = [k for k in session_related_keys if k.decode('utf-8') != session_key]
remaining_keys = [k for k in session_related_keys if k != session_key]
if remaining_keys:
self.redis_client.delete(*remaining_keys)
logger.info(f"[{session_id}] Cleaned up {len(remaining_keys)} additional session-related keys")
@ -1470,7 +1469,7 @@ class VODConnectionManager:
if self.redis_client:
session_data = self.redis_client.hgetall(session_key)
if session_data:
created_at = float(session_data.get(b'created_at', b'0').decode('utf-8'))
created_at = float(session_data.get('created_at', '0'))
if current_time - created_at > max_age_seconds:
logger.info(f"[{session_id}] Session older than {max_age_seconds}s")
stale_sessions.append(session_id)

View file

@ -93,7 +93,7 @@ class SerializableConnectionState:
content_name: str = None, client_ip: str = None,
client_user_agent: str = None, utc_start: str = None,
utc_end: str = None, offset: str = None,
worker_id: str = None, connection_type: str = "redis_backed"):
worker_id: str = None, connection_type: str = "redis_backed", user_id: str = "unknown"):
self.session_id = session_id
self.stream_url = stream_url
self.headers = headers
@ -104,6 +104,7 @@ class SerializableConnectionState:
self.last_activity = time.time()
self.request_count = 0
self.active_streams = 0
self.user_id = user_id
# Session metadata (consolidated from vod_session key)
self.content_obj_type = content_obj_type
@ -160,7 +161,8 @@ class SerializableConnectionState:
'last_seek_byte': str(self.last_seek_byte),
'last_seek_percentage': str(self.last_seek_percentage),
'total_content_size': str(self.total_content_size),
'last_seek_timestamp': str(self.last_seek_timestamp)
'last_seek_timestamp': str(self.last_seek_timestamp),
'user_id': str(self.user_id),
}
@classmethod
@ -184,7 +186,8 @@ class SerializableConnectionState:
utc_end=data.get('utc_end') or '',
offset=data.get('offset') or '',
worker_id=data.get('worker_id') or None,
connection_type=data.get('connection_type', 'redis_backed')
connection_type=data.get('connection_type', 'redis_backed'),
user_id=data.get('user_id', 'unknown')
)
obj.last_activity = float(data.get('last_activity', time.time()))
obj.request_count = int(data.get('request_count', 0))
@ -224,7 +227,7 @@ class RedisBackedVODConnection:
# Convert bytes keys/values to strings if needed
if isinstance(list(data.keys())[0], bytes):
data = {k.decode('utf-8'): v.decode('utf-8') for k, v in data.items()}
data = {k: v for k, v in data.items()}
return SerializableConnectionState.from_dict(data)
except Exception as e:
@ -281,7 +284,7 @@ class RedisBackedVODConnection:
content_name: str = None, client_ip: str = None,
client_user_agent: str = None, utc_start: str = None,
utc_end: str = None, offset: str = None,
worker_id: str = None) -> bool:
worker_id: str = None, user=None) -> bool:
"""Create a new connection state in Redis with consolidated session metadata"""
if not self._acquire_lock():
logger.warning(f"[{self.session_id}] Could not acquire lock for connection creation")
@ -309,7 +312,8 @@ class RedisBackedVODConnection:
utc_start=utc_start,
utc_end=utc_end,
offset=offset,
worker_id=worker_id
worker_id=worker_id,
user_id=user.id if user else "unknown"
)
success = self._save_connection_state(state)
@ -365,6 +369,24 @@ class RedisBackedVODConnection:
timeout=(10, 10),
allow_redirects=allow_redirects
)
# If the cached final_url returned an error (e.g. an ephemeral dispatcharr session
# that has since expired), clear it and retry from the original stream_url.
if response.status_code >= 400 and state.final_url:
logger.warning(
f"[{self.session_id}] Cached final_url returned {response.status_code}, "
f"clearing and retrying from stream_url"
)
response.close()
state.final_url = None
response = self.local_session.get(
state.stream_url,
headers=headers,
stream=True,
timeout=(10, 10),
allow_redirects=True
)
response.raise_for_status()
# Update state with response info on first request
@ -761,7 +783,7 @@ class MultiWorkerVODConnectionManager:
def stream_content_with_session(self, session_id, content_obj, stream_url, m3u_profile,
client_ip, client_user_agent, request,
utc_start=None, utc_end=None, offset=None, range_header=None):
utc_start=None, utc_end=None, offset=None, range_header=None, user=None):
"""Stream content with Redis-backed persistent connection"""
# Generate client ID
@ -858,7 +880,8 @@ class MultiWorkerVODConnectionManager:
utc_start=utc_start,
utc_end=utc_end,
offset=str(offset) if offset else None,
worker_id=self.worker_id
worker_id=self.worker_id,
user=user
):
logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection")
# Roll back the profile slot reservation since connection failed
@ -1272,14 +1295,14 @@ class MultiWorkerVODConnectionManager:
# Convert bytes to strings if needed
if isinstance(list(data.keys())[0], bytes):
data = {k.decode('utf-8'): v.decode('utf-8') for k, v in data.items()}
data = {k: v for k, v in data.items()}
last_activity = float(data.get('last_activity', 0))
active_streams = int(data.get('active_streams', 0))
# Clean up if stale and no active streams
if (current_time - last_activity > max_age_seconds) and active_streams == 0:
session_id = key.decode('utf-8').replace('vod_persistent_connection:', '')
session_id = key.replace('vod_persistent_connection:', '')
logger.info(f"Cleaning up stale connection: {session_id}")
# Clean up connection and related keys
@ -1376,7 +1399,7 @@ class MultiWorkerVODConnectionManager:
if connection_data:
# Convert bytes to strings if needed
if isinstance(list(connection_data.keys())[0], bytes):
connection_data = {k.decode('utf-8'): v.decode('utf-8') for k, v in connection_data.items()}
connection_data = {k: v for k, v in connection_data.items()}
profile_id = connection_data.get('m3u_profile_id')
if profile_id:
@ -1438,7 +1461,7 @@ class MultiWorkerVODConnectionManager:
# Convert bytes keys/values to strings if needed
if isinstance(list(connection_data.keys())[0], bytes):
connection_data = {k.decode('utf-8'): v.decode('utf-8') for k, v in connection_data.items()}
connection_data = {k: v for k, v in connection_data.items()}
# Check if content matches (using consolidated data)
stored_content_type = connection_data.get('content_obj_type', '')
@ -1448,7 +1471,7 @@ class MultiWorkerVODConnectionManager:
continue
# Extract session ID
session_id = key.decode('utf-8').replace('vod_persistent_connection:', '')
session_id = key.replace('vod_persistent_connection:', '')
# Check if Redis-backed connection exists and has no active streams
redis_connection = RedisBackedVODConnection(session_id, self.redis_client)
@ -1526,4 +1549,4 @@ class MultiWorkerVODConnectionManager:
return redis_connection.get_session_metadata()
except Exception as e:
logger.error(f"Error getting session info for {session_id}: {e}")
return None
return None

View file

@ -1,16 +1,17 @@
from django.urls import path
from . import views
from .views import stream_vod
app_name = 'vod_proxy'
urlpatterns = [
# Generic VOD streaming with session ID in path (for compatibility)
path('<str:content_type>/<uuid:content_id>/<str:session_id>', views.VODStreamView.as_view(), name='vod_stream_with_session'),
path('<str:content_type>/<uuid:content_id>/<str:session_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_session_and_profile'),
path('<str:content_type>/<uuid:content_id>/<str:session_id>', stream_vod, name='vod_stream_with_session'),
path('<str:content_type>/<uuid:content_id>/<str:session_id>/<int:profile_id>/', stream_vod, name='vod_stream_with_session_and_profile'),
# Generic VOD streaming (supports movies, episodes, series) - legacy patterns
path('<str:content_type>/<uuid:content_id>', views.VODStreamView.as_view(), name='vod_stream'),
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', views.VODStreamView.as_view(), name='vod_stream_with_profile'),
path('<str:content_type>/<uuid:content_id>', stream_vod, name='vod_stream'),
path('<str:content_type>/<uuid:content_id>/<int:profile_id>/', stream_vod, name='vod_stream_with_profile'),
# VOD playlist generation
path('playlist/', views.VODPlaylistView.as_view(), name='vod_playlist'),

File diff suppressed because it is too large Load diff

View file

@ -578,13 +578,15 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
"series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)"
]
params = []
movie_params = []
series_params = []
if search:
where_conditions[0] += " AND LOWER(movies.name) LIKE %s"
where_conditions[1] += " AND LOWER(series.name) LIKE %s"
search_param = f"%{search.lower()}%"
params.extend([search_param, search_param])
movie_params.append(search_param)
series_params.append(search_param)
if category:
if '|' in category:
@ -592,15 +594,20 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet):
if cat_type == 'movie':
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] = "1=0" # Exclude series
params.append(cat_name)
movie_params.append(cat_name)
series_params = [] # no params needed for "1=0"
elif cat_type == 'series':
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
where_conditions[0] = "1=0" # Exclude movies
params.append(cat_name)
series_params.append(cat_name)
movie_params = [] # no params needed for "1=0"
else:
where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)"
where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)"
params.extend([category, category])
movie_params.append(category)
series_params.append(category)
params = movie_params + series_params
# Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination
# This is much more efficient than Python sorting
@ -896,4 +903,3 @@ class VODLogoViewSet(viewsets.ModelViewSet):
{"error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View file

@ -3,6 +3,7 @@
import json
import ipaddress
import logging
from django.conf import settings as django_settings
from django.db import models
from rest_framework import viewsets, status
from rest_framework.response import Response
@ -301,7 +302,9 @@ def environment(request):
country_code = None
country_name = None
# 4) Get environment mode from system environment variable
# 4) Get environment mode and TLS status from settings
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
return Response(
{
"authenticated": True,
@ -309,7 +312,17 @@ def environment(request):
"local_ip": local_ip,
"country_code": country_code,
"country_name": country_name,
"env_mode": "dev" if os.getenv("DISPATCHARR_ENV") == "dev" else "prod",
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
"redis_tls": {
"enabled": getattr(django_settings, "REDIS_SSL", False),
"verify": getattr(django_settings, "REDIS_SSL_VERIFY", True),
"mtls": bool(getattr(django_settings, "REDIS_SSL_CERT", "") and getattr(django_settings, "REDIS_SSL_KEY", "")),
},
"postgres_tls": {
"enabled": postgres_ssl,
"ssl_mode": getattr(django_settings, "POSTGRES_SSL_MODE", "verify-full") if postgres_ssl else None,
"mtls": bool(getattr(django_settings, "POSTGRES_SSL_CERT", "") and getattr(django_settings, "POSTGRES_SSL_KEY", "")),
},
}
)

View file

@ -1,5 +1,6 @@
import sys
import psycopg2
from psycopg2 import sql
from django.core.management.base import BaseCommand
from django.conf import settings
from django.db import connection
@ -15,6 +16,13 @@ class Command(BaseCommand):
host = db_settings.get('HOST', 'localhost')
port = db_settings.get('PORT', 5432)
# Read TLS parameters from Django OPTIONS (populated when POSTGRES_SSL=true)
db_options = db_settings.get('OPTIONS', {})
ssl_kwargs = {}
for key in ('sslmode', 'sslrootcert', 'sslcert', 'sslkey'):
if key in db_options:
ssl_kwargs[key] = db_options[key]
self.stdout.write(self.style.WARNING(
f"WARNING: This will irreversibly drop the entire database '{db_name}'!"
))
@ -30,13 +38,13 @@ class Command(BaseCommand):
maintenance_db = 'postgres'
try:
self.stdout.write("Connecting to maintenance database...")
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port)
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, **ssl_kwargs)
conn.autocommit = True
cur = conn.cursor()
self.stdout.write(f"Dropping database '{db_name}'...")
cur.execute(f"DROP DATABASE IF EXISTS {db_name};")
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))
self.stdout.write(f"Creating database '{db_name}'...")
cur.execute(f"CREATE DATABASE {db_name};")
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name)))
cur.close()
conn.close()
self.stdout.write(self.style.SUCCESS(f"Database '{db_name}' has been dropped and recreated."))

View file

@ -0,0 +1,24 @@
# Generated by Django 5.1.6 on 2025-03-01 14:01
from django.db import migrations
from django.utils.text import slugify
def preload_user_limit_settings(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
CoreSettings.objects.create(
key="user_limit_settings",
name="User Limit Settings",
value={},
)
class Migration(migrations.Migration):
dependencies = [
("core", "0021_systemnotification_notificationdismissal"),
]
operations = [
migrations.RunPython(preload_user_limit_settings),
]

View file

@ -156,6 +156,7 @@ PROXY_SETTINGS_KEY = "proxy_settings"
NETWORK_ACCESS_KEY = "network_access"
SYSTEM_SETTINGS_KEY = "system_settings"
EPG_SETTINGS_KEY = "epg_settings"
USER_LIMITS_SETTINGS_KEY = "user_limit_settings"
class CoreSettings(models.Model):
@ -362,6 +363,15 @@ class CoreSettings(models.Model):
cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value})
return value
@classmethod
def get_user_limits_settings(cls):
return cls._get_group(USER_LIMITS_SETTINGS_KEY, {
"terminate_on_limit_exceeded": True,
"prioritize_single_client_channels": True,
"ignore_same_channel_connections": False,
"terminate_oldest": True,
})
class SystemEvent(models.Model):
"""

View file

@ -201,10 +201,6 @@ class RedisPubSubManager:
channel = message.get('channel')
if channel:
# Decode binary channel name if needed
if isinstance(channel, bytes):
channel = channel.decode('utf-8')
# Find and call the appropriate handler
handler = self.message_handlers.get(channel)
if handler:

View file

@ -404,7 +404,7 @@ def fetch_channel_stats():
while True:
cursor, keys = redis_client.scan(cursor, match=channel_pattern)
for key in keys:
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key.decode('utf-8'))
channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key)
if channel_id_match:
ch_id = channel_id_match.group(1)
channel_info = ChannelStatus.get_basic_channel_info(ch_id)

View file

@ -1,3 +1,5 @@
from unittest.mock import patch, MagicMock
from django.test import TestCase
from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY
@ -148,3 +150,74 @@ class EpgIgnoreListsTest(TestCase):
]:
self._set_epg_field_raw(field, "not a list")
self.assertEqual(getter(), [])
class DropDBCommandTlsTest(TestCase):
"""Verify dropdb management command passes TLS parameters to psycopg2."""
databases = []
_DB_WITH_TLS = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'testdb',
'USER': 'testuser',
'PASSWORD': 'testpass',
'HOST': 'localhost',
'PORT': 5432,
'OPTIONS': {
'sslmode': 'verify-full',
'sslrootcert': '/certs/ca.crt',
'sslcert': '/certs/client.crt',
'sslkey': '/certs/client.key',
},
}
}
_DB_NO_TLS = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'testdb',
'USER': 'testuser',
'PASSWORD': 'testpass',
'HOST': 'localhost',
'PORT': 5432,
}
}
@patch('core.management.commands.dropdb.psycopg2.connect')
@patch('core.management.commands.dropdb.connection')
@patch('builtins.input', return_value='yes')
def test_dropdb_passes_ssl_kwargs_when_tls_enabled(self, _inp, _conn, mock_connect):
mock_pg = MagicMock()
mock_connect.return_value = mock_pg
mock_pg.cursor.return_value = MagicMock()
with self.settings(DATABASES=self._DB_WITH_TLS):
from django.core.management import call_command
call_command('dropdb')
mock_connect.assert_called_once_with(
dbname='postgres', user='testuser', password='testpass',
host='localhost', port=5432,
sslmode='verify-full',
sslrootcert='/certs/ca.crt',
sslcert='/certs/client.crt',
sslkey='/certs/client.key',
)
@patch('core.management.commands.dropdb.psycopg2.connect')
@patch('core.management.commands.dropdb.connection')
@patch('builtins.input', return_value='yes')
def test_dropdb_no_ssl_kwargs_when_tls_disabled(self, _inp, _conn, mock_connect):
mock_pg = MagicMock()
mock_connect.return_value = mock_pg
mock_pg.cursor.return_value = MagicMock()
with self.settings(DATABASES=self._DB_NO_TLS):
from django.core.management import call_command
call_command('dropdb')
mock_connect.assert_called_once_with(
dbname='postgres', user='testuser', password='testpass',
host='localhost', port=5432,
)

View file

@ -13,6 +13,8 @@ from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
import gc
_REDIS_TLS_HINT = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)"
logger = logging.getLogger(__name__)
# Import the command detector
@ -43,103 +45,116 @@ def natural_sort_key(text):
class RedisClient:
_client = None
_buffer = None
_pubsub_client = None
@classmethod
def get_client(cls, max_retries=5, retry_interval=1):
if cls._client is None:
retry_count = 0
while retry_count < max_retries:
def _init_client(cls, decode_responses=True, max_retries=5, retry_interval=1):
retry_count = 0
while retry_count < max_retries:
try:
# Get connection parameters from settings or environment
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
# Use standardized settings
socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5)
socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5)
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True)
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
# TLS params from settings (empty dict when TLS is disabled)
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
# Create Redis client with better defaults
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
socket_timeout=socket_timeout,
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,
health_check_interval=health_check_interval,
retry_on_timeout=retry_on_timeout,
decode_responses=decode_responses,
**ssl_params
)
# Validate connection with ping
client.ping()
# Disable persistence on first connection - improves performance
# Only try to disable if not in a read-only environment
try:
# Get connection parameters from settings or environment
redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost'))
redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379)))
redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0)))
redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', ''))
redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', ''))
client.config_set('save', '') # Disable RDB snapshots
client.config_set('appendonly', 'no') # Disable AOF logging
# Use standardized settings
socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5)
socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5)
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True)
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
# Disable protected mode when in debug mode
if os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true':
client.config_set('protected-mode', 'no') # Disable protected mode in debug
logger.warning("Redis protected mode disabled for debug environment")
# Create Redis client with better defaults
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None,
socket_timeout=socket_timeout,
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,
health_check_interval=health_check_interval,
retry_on_timeout=retry_on_timeout
)
# Validate connection with ping
client.ping()
# Disable persistence on first connection - improves performance
# Only try to disable if not in a read-only environment
try:
client.config_set('save', '') # Disable RDB snapshots
client.config_set('appendonly', 'no') # Disable AOF logging
# Set optimal memory settings with environment variable support
# Get max memory from environment or use a larger default (512MB instead of 256MB)
#max_memory = os.environ.get('REDIS_MAX_MEMORY', '512mb')
#eviction_policy = os.environ.get('REDIS_EVICTION_POLICY', 'allkeys-lru')
# Apply memory settings
#client.config_set('maxmemory-policy', eviction_policy)
#client.config_set('maxmemory', max_memory)
#logger.info(f"Redis configured with maxmemory={max_memory}, policy={eviction_policy}")
# Disable protected mode when in debug mode
if os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true':
client.config_set('protected-mode', 'no') # Disable protected mode in debug
logger.warning("Redis protected mode disabled for debug environment")
logger.trace("Redis persistence disabled for better performance")
except redis.exceptions.ResponseError as e:
# Improve error handling for Redis configuration errors
if "OOM" in str(e):
logger.error(f"Redis OOM during configuration: {e}")
# Try to increase maxmemory as an emergency measure
try:
client.config_set('maxmemory', '768mb')
logger.warning("Applied emergency Redis memory increase to 768MB")
except:
pass
else:
logger.error(f"Redis configuration error: {e}")
logger.info(f"Connected to Redis at {redis_host}:{redis_port}/{redis_db}")
cls._client = client
break
except (ConnectionError, TimeoutError) as e:
retry_count += 1
if retry_count >= max_retries:
logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}")
return None
logger.trace("Redis persistence disabled for better performance")
except redis.exceptions.ResponseError as e:
# Improve error handling for Redis configuration errors
if "OOM" in str(e):
logger.error(f"Redis OOM during configuration: {e}")
# Try to increase maxmemory as an emergency measure
try:
client.config_set('maxmemory', '768mb')
logger.warning("Applied emergency Redis memory increase to 768MB")
except:
pass
else:
# Use exponential backoff for retries
wait_time = retry_interval * (2 ** (retry_count - 1))
logger.warning(f"Redis connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})")
time.sleep(wait_time)
logger.error(f"Redis configuration error: {e}")
except Exception as e:
logger.error(f"Unexpected error connecting to Redis: {e}")
logger.info(f"Connected to Redis at {redis_host}:{redis_port}/{redis_db}")
return client
except (ConnectionError, TimeoutError) as e:
retry_count += 1
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
if retry_count >= max_retries:
logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}")
return None
else:
# Use exponential backoff for retries
wait_time = retry_interval * (2 ** (retry_count - 1))
logger.warning(f"Redis connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})")
time.sleep(wait_time)
except Exception as e:
_tls_hint = ""
try:
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
except NameError:
pass
logger.error(f"Unexpected error connecting to Redis: {e}{_tls_hint}")
return None
return None
@classmethod
def get_client(cls, max_retries=5, retry_interval=1):
"""Get Redis client optimized for non-binary data (decoded responses)"""
if cls._client is None:
cls._client = cls._init_client(decode_responses=True, max_retries=max_retries, retry_interval=retry_interval)
return cls._client
@classmethod
def get_buffer(cls, max_retries=5, retry_interval=1):
"""Get Redis client optimized for binary data (no decoding)"""
if cls._buffer is None:
cls._buffer = cls._init_client(decode_responses=False, max_retries=max_retries, retry_interval=retry_interval)
return cls._buffer
@classmethod
def get_pubsub_client(cls, max_retries=5, retry_interval=1):
"""Get Redis client optimized for PubSub operations"""
@ -161,6 +176,8 @@ class RedisClient:
health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30)
retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True)
ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {})
# Create Redis client with PubSub-optimized settings - no timeout
client = redis.Redis(
host=redis_host,
@ -172,7 +189,9 @@ class RedisClient:
socket_connect_timeout=socket_connect_timeout,
socket_keepalive=socket_keepalive,
health_check_interval=health_check_interval,
retry_on_timeout=retry_on_timeout
retry_on_timeout=retry_on_timeout,
decode_responses=True,
**ssl_params
)
# Validate connection with ping
@ -185,8 +204,9 @@ class RedisClient:
except (ConnectionError, TimeoutError) as e:
retry_count += 1
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
if retry_count >= max_retries:
logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}")
logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}{_tls_hint}")
return None
else:
# Use exponential backoff for retries
@ -195,7 +215,8 @@ class RedisClient:
time.sleep(wait_time)
except Exception as e:
logger.error(f"Unexpected error connecting to Redis for PubSub: {e}")
_tls_hint = _REDIS_TLS_HINT if ssl_params else ""
logger.error(f"Unexpected error connecting to Redis for PubSub: {e}{_tls_hint}")
return None
return cls._pubsub_client

View file

@ -4,7 +4,7 @@ from shlex import split as shlex_split
import sys
import subprocess
import logging
import re
import regex
import redis
from django.conf import settings
@ -42,12 +42,14 @@ def stream_view(request, channel_uuid):
redis_db = int(getattr(settings, "REDIS_DB", "0"))
redis_password = getattr(settings, "REDIS_PASSWORD", "")
redis_user = getattr(settings, "REDIS_USER", "")
ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {})
redis_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None
username=redis_user if redis_user else None,
**ssl_params
)
# Retrieve the channel by the provided stream_id.
@ -130,10 +132,13 @@ def stream_view(request, channel_uuid):
# Prepare the pattern replacement.
logger.debug("Executing the following pattern replacement:")
logger.debug(f" search: {active_profile.search_pattern}")
safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', active_profile.replace_pattern)
# Convert JS-style backreferences in replace: $<name> -> \g<name>, $1 -> \1
safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern)
safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern)
logger.debug(f" replace: {active_profile.replace_pattern}")
logger.debug(f" safe replace: {safe_replace_pattern}")
stream_url = re.sub(active_profile.search_pattern, safe_replace_pattern, input_url)
# regex module accepts JS-style (?<name>...) named groups natively
stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url)
logger.debug(f"Generated stream url: {stream_url}")
# Get the stream profile set on the channel.

View file

@ -62,7 +62,7 @@ class Client:
url = f"{self.server_url}/{endpoint}"
logger.debug(f"XC API Request: {url} with params: {params}")
response = self.session.get(url, params=params, timeout=30)
response = self.session.get(url, params=params, timeout=60)
response.raise_for_status()
# Check if response is empty

View file

@ -1,6 +1,6 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
import re, logging
import regex, logging
logger = logging.getLogger(__name__)
@ -54,9 +54,9 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
# Apply the transformation using the replace_with_mark function
try:
search_preview = re.sub(data["search"], replace_with_mark, data["url"])
search_preview = regex.sub(data["search"], replace_with_mark, data["url"])
except Exception as e:
search_preview = data["search"]
search_preview = data["url"]
logger.error(f"Failed to generate replace preview: {e}")
result = transform_url(data["url"], data["search"], data["replace"])

View file

@ -48,7 +48,7 @@ class PersistentLock:
Returns True if the expiration was successfully extended.
"""
current_value = self.redis_client.get(self.lock_key)
if current_value and current_value.decode("utf-8") == self.lock_token:
if current_value and current_value == self.lock_token:
self.redis_client.expire(self.lock_key, self.lock_timeout)
self.has_lock = False
return True
@ -74,18 +74,40 @@ class PersistentLock:
# Example usage (for testing purposes only):
if __name__ == "__main__":
import os
import sys
# Connect to Redis using environment variables; adjust connection parameters as needed.
redis_host = os.environ.get("REDIS_HOST", "localhost")
redis_port = int(os.environ.get("REDIS_PORT", 6379))
redis_db = int(os.environ.get("REDIS_DB", 0))
redis_password = os.environ.get("REDIS_PASSWORD", "")
redis_user = os.environ.get("REDIS_USER", "")
ssl_kwargs = {}
if os.environ.get("REDIS_SSL", "false").lower() == "true":
import ssl as _ssl
ssl_kwargs["ssl"] = True
ssl_kwargs["ssl_cert_reqs"] = (
_ssl.CERT_REQUIRED if os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true"
else _ssl.CERT_NONE
)
for env_var, key in [
("REDIS_SSL_CA_CERT", "ssl_ca_certs"),
("REDIS_SSL_CERT", "ssl_certfile"),
("REDIS_SSL_KEY", "ssl_keyfile"),
]:
path = os.environ.get(env_var, "")
if path:
if not os.path.isfile(path):
print(f"Redis TLS: {env_var}={path!r} — file not found.")
sys.exit(1)
ssl_kwargs[key] = path
client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
password=redis_password if redis_password else None,
username=redis_user if redis_user else None
username=redis_user if redis_user else None,
**ssl_kwargs
)
lock = PersistentLock(client, "lock:example_account", lock_timeout=120)

View file

@ -1,7 +1,23 @@
import os
import ssl
from pathlib import Path
from datetime import timedelta
from urllib.parse import quote_plus
from django.core.exceptions import ImproperlyConfigured
def _validate_tls_cert_paths(paths, service_name):
"""Validate that configured TLS certificate file paths exist on disk.
Raises ImproperlyConfigured with a clear message identifying the
service and missing file so operators can fix their environment.
"""
for env_var, file_path in paths:
if file_path and not Path(file_path).is_file():
raise ImproperlyConfigured(
f"{service_name} TLS: {env_var}={file_path!r} — file not found. "
f"Check that the certificate file exists and the volume is mounted correctly."
)
BASE_DIR = Path(__file__).resolve().parent.parent
@ -12,6 +28,37 @@ REDIS_DB = os.environ.get("REDIS_DB", "0")
REDIS_USER = os.environ.get("REDIS_USER", "")
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
# Redis TLS configuration
REDIS_SSL = os.environ.get("REDIS_SSL", "false").lower() == "true"
REDIS_SSL_VERIFY = os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true"
REDIS_SSL_CA_CERT = os.environ.get("REDIS_SSL_CA_CERT", "")
REDIS_SSL_CERT = os.environ.get("REDIS_SSL_CERT", "")
REDIS_SSL_KEY = os.environ.get("REDIS_SSL_KEY", "")
# Reusable dict of SSL kwargs for redis.Redis() constructors
REDIS_SSL_PARAMS = {}
if REDIS_SSL:
_validate_tls_cert_paths([
("REDIS_SSL_CA_CERT", REDIS_SSL_CA_CERT),
("REDIS_SSL_CERT", REDIS_SSL_CERT),
("REDIS_SSL_KEY", REDIS_SSL_KEY),
], "Redis")
REDIS_SSL_PARAMS["ssl"] = True
REDIS_SSL_PARAMS["ssl_cert_reqs"] = ssl.CERT_REQUIRED if REDIS_SSL_VERIFY else ssl.CERT_NONE
if REDIS_SSL_CA_CERT:
REDIS_SSL_PARAMS["ssl_ca_certs"] = REDIS_SSL_CA_CERT
if REDIS_SSL_CERT:
REDIS_SSL_PARAMS["ssl_certfile"] = REDIS_SSL_CERT
if REDIS_SSL_KEY:
REDIS_SSL_PARAMS["ssl_keyfile"] = REDIS_SSL_KEY
_mtls = "enabled" if REDIS_SSL_CERT and REDIS_SSL_KEY else "disabled"
_verify = "on" if REDIS_SSL_VERIFY else "off"
print(f"Redis TLS: enabled (verify={_verify}, mTLS={_mtls})")
else:
print("Redis TLS: disabled")
# Set DEBUG to True for development, False for production
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
DEBUG = True
@ -120,20 +167,46 @@ TEMPLATES = [
WSGI_APPLICATION = "dispatcharr.wsgi.application"
ASGI_APPLICATION = "dispatcharr.asgi.application"
_redis_scheme = "rediss" if REDIS_SSL else "redis"
# URL-encoded auth string shared by CHANNEL_LAYERS and Celery broker URLs
if REDIS_PASSWORD:
_encoded_password = quote_plus(REDIS_PASSWORD)
if REDIS_USER:
_redis_auth = f"{quote_plus(REDIS_USER)}:{_encoded_password}@"
else:
_redis_auth = f":{_encoded_password}@"
else:
_redis_auth = ""
_channels_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
# channels_redis accepts either a URL string or a dict with "address" + kwargs.
# When TLS is enabled, pass SSL params alongside the URL so the connection pool
# uses the correct CA cert and verification settings.
if REDIS_SSL:
# Filter out "ssl" key — the rediss:// scheme already enables SSL.
# Passing ssl=True as a kwarg to aioredis from_url causes an error.
_channels_ssl = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"}
_channels_host = {"address": _channels_redis_url, **_channels_ssl}
else:
_channels_host = _channels_redis_url
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": ["redis://{redis_auth}{host}:{port}/{db}".format(
redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "",
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB
)], # URL format supports authentication
"hosts": [_channels_host],
},
},
}
# PostgreSQL TLS configuration (defined before DATABASES for module-level access)
POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").lower() == "true"
POSTGRES_SSL_MODE = os.environ.get("POSTGRES_SSL_MODE", "verify-full")
POSTGRES_SSL_CA_CERT = os.environ.get("POSTGRES_SSL_CA_CERT", "")
POSTGRES_SSL_CERT = os.environ.get("POSTGRES_SSL_CERT", "")
POSTGRES_SSL_KEY = os.environ.get("POSTGRES_SSL_KEY", "")
if os.getenv("DB_ENGINE", None) == "sqlite":
DATABASES = {
"default": {
@ -154,6 +227,28 @@ else:
}
}
if POSTGRES_SSL:
_validate_tls_cert_paths([
("POSTGRES_SSL_CA_CERT", POSTGRES_SSL_CA_CERT),
("POSTGRES_SSL_CERT", POSTGRES_SSL_CERT),
("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY),
], "PostgreSQL")
DATABASES["default"]["OPTIONS"] = {
"sslmode": POSTGRES_SSL_MODE,
}
if POSTGRES_SSL_CA_CERT:
DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT
if POSTGRES_SSL_CERT:
DATABASES["default"]["OPTIONS"]["sslcert"] = POSTGRES_SSL_CERT
if POSTGRES_SSL_KEY:
DATABASES["default"]["OPTIONS"]["sslkey"] = POSTGRES_SSL_KEY
_mtls = "enabled" if POSTGRES_SSL_CERT and POSTGRES_SSL_KEY else "disabled"
print(f"PostgreSQL TLS: enabled (sslmode={POSTGRES_SSL_MODE}, mTLS={_mtls})")
else:
print("PostgreSQL TLS: disabled")
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
@ -208,22 +303,52 @@ STATICFILES_DIRS = [
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
# Build default Redis URL from components for Celery with optional authentication
# Build auth string conditionally with URL encoding for special characters
if REDIS_PASSWORD:
encoded_password = quote_plus(REDIS_PASSWORD)
if REDIS_USER:
encoded_user = quote_plus(REDIS_USER)
redis_auth = f"{encoded_user}:{encoded_password}@"
else:
redis_auth = f":{encoded_password}@"
_default_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
# Celery/Kombu require SSL parameters in the URL query string because
# internal URL parsing can overwrite the CELERY_BROKER_USE_SSL dict.
if REDIS_SSL:
_celery_ssl_params = [
f"ssl_cert_reqs={'CERT_REQUIRED' if REDIS_SSL_VERIFY else 'CERT_NONE'}",
]
if REDIS_SSL_CA_CERT:
_celery_ssl_params.append(f"ssl_ca_certs={REDIS_SSL_CA_CERT}")
if REDIS_SSL_CERT:
_celery_ssl_params.append(f"ssl_certfile={REDIS_SSL_CERT}")
if REDIS_SSL_KEY:
_celery_ssl_params.append(f"ssl_keyfile={REDIS_SSL_KEY}")
_default_celery_url = f"{_default_redis_url}?{'&'.join(_celery_ssl_params)}"
else:
redis_auth = ""
_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url)
_default_celery_url = _default_redis_url
CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_celery_url)
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
# Validate that URL overrides don't conflict with TLS settings
for _url_var, _url_val in [
("CELERY_BROKER_URL", CELERY_BROKER_URL),
("CELERY_RESULT_BACKEND", CELERY_RESULT_BACKEND),
]:
_is_override = os.environ.get(_url_var) is not None
if not _is_override:
continue
_url_is_ssl = _url_val.startswith("rediss://")
if REDIS_SSL and not _url_is_ssl:
raise ImproperlyConfigured(
f"REDIS_SSL is enabled but {_url_var} uses redis:// (plaintext). "
f"Change the URL scheme to rediss:// or remove the {_url_var} override."
)
if not REDIS_SSL and _url_is_ssl:
raise ImproperlyConfigured(
f"{_url_var} uses rediss:// (TLS) but REDIS_SSL is not enabled. "
f"Set REDIS_SSL=true and configure the TLS certificate settings."
)
# Celery TLS configuration — required in addition to the rediss:// URL scheme.
# Uses the same cert params as REDIS_SSL_PARAMS, minus the "ssl" key that
# redis-py needs but Celery/Kombu does not.
if REDIS_SSL:
CELERY_BROKER_USE_SSL = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"}
CELERY_RESULT_BACKEND_USE_SSL = CELERY_BROKER_USE_SSL
# Configure Redis key prefix
CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = {
"global_keyprefix": "celery-tasks:", # Set the Redis key prefix for Celery
@ -299,8 +424,19 @@ SIMPLE_JWT = {
"BLACKLIST_AFTER_ROTATION": True, # Optional: Whether to blacklist refresh tokens
}
# Redis connection settings
# Redis connection settings — _default_redis_url uses rediss:// when REDIS_SSL is enabled
REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url)
if os.environ.get("REDIS_URL") is not None:
if REDIS_SSL and not REDIS_URL.startswith("rediss://"):
raise ImproperlyConfigured(
"REDIS_SSL is enabled but REDIS_URL uses redis:// (plaintext). "
"Change the URL scheme to rediss:// or remove the REDIS_URL override."
)
if not REDIS_SSL and REDIS_URL.startswith("rediss://"):
raise ImproperlyConfigured(
"REDIS_URL uses rediss:// (TLS) but REDIS_SSL is not enabled. "
"Set REDIS_SSL=true and configure the TLS certificate settings."
)
REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds
REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds
REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds

View file

@ -6,7 +6,7 @@ from django.views.generic import TemplateView, RedirectView
from .routing import websocket_urlpatterns
from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv
from apps.proxy.ts_proxy.views import stream_xc
from apps.output.views import xc_movie_stream, xc_series_stream
from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode
urlpatterns = [
# API Routes
@ -44,13 +44,13 @@ urlpatterns = [
# XC VOD endpoints
path(
"movie/<str:username>/<str:password>/<str:stream_id>.<str:extension>",
xc_movie_stream,
name="xc_movie_stream",
stream_xc_movie,
name="stream_xc_movie",
),
path(
"series/<str:username>/<str:password>/<str:stream_id>.<str:extension>",
xc_series_stream,
name="xc_series_stream",
stream_xc_episode,
name="stream_xc_episode",
),
# Admin
path("admin", RedirectView.as_view(url="/admin/", permanent=True)),

View file

@ -12,7 +12,7 @@ COPY ./frontend /app/frontend
# remove any node_modules that may have been copied from the host (x86)
RUN rm -rf node_modules || true; \
npm install --no-audit --progress=false;
RUN npm run build; \
RUN npm run build && \
rm -rf node_modules .cache
# --- Redeclare build arguments for the next stage ---
@ -32,9 +32,9 @@ COPY . /app
COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default
# Fix line endings and make entrypoint scripts executable
RUN for f in /app/docker/entrypoint*.sh; do \
if [ -f "$f" ]; then \
sed -i 's/\r$//' "$f" && chmod +x "$f"; \
fi; \
if [ -f "$f" ]; then \
sed -i 's/\r$//' "$f" && chmod +x "$f"; \
fi; \
done
# Clean out existing frontend folder
RUN rm -rf /app/frontend

View file

@ -14,6 +14,7 @@ services:
- 9191:9191
volumes:
- ./data:/data
#- ./certs:/certs:ro # TLS certificates (optional)
depends_on:
db:
condition: service_healthy
@ -33,15 +34,26 @@ services:
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
# PostgreSQL TLS (optional) — mount certs via the volume above
#- POSTGRES_SSL=true # required to enable TLS
#- POSTGRES_SSL_MODE=verify-full # optional: verify-full (default) | verify-ca | require
#- POSTGRES_SSL_CA_CERT=/certs/postgres/ca.crt # optional: CA cert to verify the server
#- POSTGRES_SSL_CERT=/certs/postgres/client.crt # optional: client cert (only if server requires client auth)
#- POSTGRES_SSL_KEY=/certs/postgres/client.key # optional: client key (only if server requires client auth)
# Redis Connection
- REDIS_HOST=redis
- REDIS_PORT=6379
# Redis Authentication (Optional)
# Uncomment and set if your Redis requires authentication:
#- REDIS_PASSWORD=your_strong_redis_password
#- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below
# Redis TLS (optional) — mount certs via the volume above
#- REDIS_SSL=true # required to enable TLS
#- REDIS_SSL_VERIFY=true # optional: set false for self-signed certs without a CA
#- REDIS_SSL_CA_CERT=/certs/redis/ca.crt # optional: CA cert to verify the server
#- REDIS_SSL_CERT=/certs/redis/client.crt # optional: client cert (only if server requires client auth)
#- REDIS_SSL_KEY=/certs/redis/client.key # optional: client key (only if server requires client auth)
# Logging
- DISPATCHARR_LOG_LEVEL=info
@ -95,6 +107,7 @@ services:
condition: service_started
volumes:
- ./data:/data
#- ./certs:/certs:ro # TLS certificates (optional)
extra_hosts:
- "host.docker.internal:host-gateway"
entrypoint: ["/app/docker/entrypoint.celery.sh"]
@ -108,21 +121,30 @@ services:
# Must match the web service port for DVR recording and internal API calls
- DISPATCHARR_PORT=9191
# PostgreSQL Connection
# PostgreSQL — must match web service settings
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_DB=dispatcharr
- POSTGRES_USER=dispatch
- POSTGRES_PASSWORD=secret
# PostgreSQL TLS — must match web service
#- POSTGRES_SSL=true
#- POSTGRES_SSL_MODE=verify-full
#- POSTGRES_SSL_CA_CERT=/certs/postgres/ca.crt
#- POSTGRES_SSL_CERT=/certs/postgres/client.crt
#- POSTGRES_SSL_KEY=/certs/postgres/client.key
# Redis Connection
# Redis — must match web service settings
- REDIS_HOST=redis
- REDIS_PORT=6379
# Redis Authentication (Optional)
# Uncomment and set if your Redis requires authentication:
#- REDIS_PASSWORD=your_strong_redis_password
#- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below
#- REDIS_USER=your_redis_username
# Redis TLS — must match web service
#- REDIS_SSL=true
#- REDIS_SSL_VERIFY=true
#- REDIS_SSL_CA_CERT=/certs/redis/ca.crt
#- REDIS_SSL_CERT=/certs/redis/client.crt
#- REDIS_SSL_KEY=/certs/redis/client.key
# Logging
- DISPATCHARR_LOG_LEVEL=info

View file

@ -36,6 +36,10 @@ if [ "$USE_LEGACY_NUMPY" = "true" ]; then
fi
fi
# Fix TLS client key permissions/ownership for PostgreSQL.
FIXED_KEY_PATH="/data/.pg-client-celery.key"
. /app/docker/init/00-fix-pg-ssl-key.sh
# Wait for migrations to complete
# Uses 'migrate --check' which exits 0 only when all migrations are applied,
# and exits 1 on unapplied migrations OR connection errors (safe either way)

View file

@ -30,7 +30,14 @@ echo_with_timestamp() {
# Set PostgreSQL environment variables
export POSTGRES_DB=${POSTGRES_DB:-dispatcharr}
export POSTGRES_USER=${POSTGRES_USER:-dispatch}
export POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-secret}
# AIO mode: default to 'secret' for internal DB.
# Modular mode + TLS: no default — cert-only auth (mTLS) uses no password.
# Modular mode + no TLS: preserve 'secret' default for backward compatibility.
if [[ "${DISPATCHARR_ENV:-}" == "modular" && "${POSTGRES_SSL:-}" == "true" ]]; then
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
else
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-secret}"
fi
export POSTGRES_HOST=${POSTGRES_HOST:-localhost}
export POSTGRES_PORT=${POSTGRES_PORT:-5432}
export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1)
@ -96,6 +103,20 @@ echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'"
# Also make the log level available in /etc/environment for all login shells
#grep -q "DISPATCHARR_LOG_LEVEL" /etc/environment || echo "DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL}" >> /etc/environment
# Translate Dispatcharr POSTGRES_SSL_* env vars into libpq-recognized PGSSL*
# env vars. Called once before any external PostgreSQL connection; all child
# processes (psql, pg_dump, pg_isready, createdb, dropdb) inherit these
# automatically. No-op when POSTGRES_SSL is not "true".
setup_pg_ssl_env() {
if [ "${POSTGRES_SSL:-false}" != "true" ]; then
return 0
fi
export PGSSLMODE="${POSTGRES_SSL_MODE:-verify-full}"
if [ -n "${POSTGRES_SSL_CA_CERT:-}" ]; then export PGSSLROOTCERT="$POSTGRES_SSL_CA_CERT"; fi
if [ -n "${POSTGRES_SSL_CERT:-}" ]; then export PGSSLCERT="$POSTGRES_SSL_CERT"; fi
if [ -n "${POSTGRES_SSL_KEY:-}" ]; then export PGSSLKEY="$POSTGRES_SSL_KEY"; fi
}
# READ-ONLY - don't let users change these
export POSTGRES_DIR=/data/db
@ -112,6 +133,14 @@ variables=(
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
)
# TLS variables are optional — only propagate when set to avoid noisy warnings
for _tls_var in POSTGRES_SSL POSTGRES_SSL_MODE POSTGRES_SSL_CA_CERT POSTGRES_SSL_CERT POSTGRES_SSL_KEY \
REDIS_SSL REDIS_SSL_VERIFY REDIS_SSL_CA_CERT REDIS_SSL_CERT REDIS_SSL_KEY; do
if [ -n "${!_tls_var+x}" ]; then
variables+=("$_tls_var")
fi
done
# Truncate files before rewriting
> /etc/profile.d/dispatcharr.sh
@ -146,6 +175,22 @@ fi
echo "Starting user setup..."
. /app/docker/init/01-user-setup.sh
# Fix TLS client key permissions/ownership BEFORE any external PG connections.
# Must run after 01-user-setup.sh (user exists for chown) and before
# 02-postgres.sh / pg_isready (which make the first external PG connections).
FIXED_KEY_PATH="/data/.pg-client.key"
. /app/docker/init/00-fix-pg-ssl-key.sh
# Propagate the fixed path to login shells (su - strips env vars)
if [ "${POSTGRES_SSL_KEY:-}" = "$FIXED_KEY_PATH" ]; then
sed -i "/^POSTGRES_SSL_KEY=/d" /etc/environment
echo "POSTGRES_SSL_KEY='$FIXED_KEY_PATH'" >> /etc/environment
sed -i "s|export POSTGRES_SSL_KEY=.*|export POSTGRES_SSL_KEY='$FIXED_KEY_PATH'|" /etc/profile.d/dispatcharr.sh
fi
# Export libpq TLS env vars so all subsequent psql/pg_dump/pg_isready calls
# (in 02-postgres.sh, modular-mode checks, etc.) use TLS automatically.
setup_pg_ssl_env
# Initialize PostgreSQL (script handles modular vs internal mode internally)
echo "Setting up PostgreSQL..."
. /app/docker/init/02-postgres.sh

View file

@ -0,0 +1,44 @@
#!/bin/bash
#
# Fix TLS client key permissions and ownership for PostgreSQL.
# libpq requires the client key to be 0600 or stricter.
#
# Triggers on:
# - Permissions too open (Docker Desktop mounts files as 0777)
# - Wrong ownership (Kubernetes secrets / Docker volumes mount as root;
# the application user can't read a root-owned 0600 key)
# - Read-only source (volume mounted :ro — can't chmod in place)
#
# Usage: source this script with FIXED_KEY_PATH set to the destination.
# FIXED_KEY_PATH="/data/.pg-client.key"
# . /app/docker/init/00-fix-pg-ssl-key.sh
#
# After sourcing, POSTGRES_SSL_KEY is updated to the fixed path if a copy
# was needed. The caller is responsible for propagating the new value to
# /etc/environment or profile.d if required.
: "${FIXED_KEY_PATH:?FIXED_KEY_PATH must be set before sourcing fix-pg-ssl-key.sh}"
if [ -n "${POSTGRES_SSL_KEY:-}" ] && [ -f "$POSTGRES_SSL_KEY" ]; then
_key_perms=$(stat -c '%a' "$POSTGRES_SSL_KEY" 2>/dev/null)
_key_owner=$(stat -c '%u' "$POSTGRES_SSL_KEY" 2>/dev/null)
_needs_fix=false
if [ "$_key_perms" != "600" ] && [ "$_key_perms" != "640" ]; then
_needs_fix=true
elif [ "$(id -u)" = "0" ] && [ -n "${PUID:-}" ] && [ "$_key_owner" != "$PUID" ]; then
_needs_fix=true
fi
if [ "$_needs_fix" = true ]; then
cp "$POSTGRES_SSL_KEY" "$FIXED_KEY_PATH"
chmod 600 "$FIXED_KEY_PATH"
if [ "$(id -u)" = "0" ] && [ -n "${PUID:-}" ]; then
chown "${PUID}:${PGID:-$PUID}" "$FIXED_KEY_PATH"
fi
export POSTGRES_SSL_KEY="$FIXED_KEY_PATH"
echo "Fixed PostgreSQL client key (perms: ${_key_perms}, owner: ${_key_owner}${PUID:-root}:600)"
fi
unset _key_perms _key_owner _needs_fix
fi

View file

@ -1,25 +1,33 @@
#!/bin/bash
echo "🚀 Development Mode - Setting up Frontend..."
if [ ! -e "/tmp/init" ]; then
echo "🚀 Development Mode - Setting up Frontend..."
# Install Node.js
if ! command -v node 2>&1 >/dev/null
then
echo "=== setting up nodejs ==="
curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get update
apt-get install -y --no-install-recommends \
nodejs
fi
# Install frontend dependencies
cd /app/frontend && npm install
# Install Python dependencies using UV
cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev
# Install debugpy for remote debugging
if [ "$DISPATCHARR_DEBUG" = "true" ]; then
echo "=== setting up debugpy ==="
uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy
# Install Node.js
if ! command -v node 2>&1 >/dev/null
then
echo "=== setting up nodejs ==="
curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get update
apt-get install -y --no-install-recommends \
nodejs
fi
# Install frontend dependencies
cd /app/frontend && npm install
# Install Python dependencies using UV
cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev
# Install debugpy for remote debugging
if [ "$DISPATCHARR_DEBUG" = "true" ]; then
echo "=== setting up debugpy ==="
uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy
fi
if [[ "$DISPATCHARR_ENV" = "dev" ]]; then
touch /tmp/init
fi
else
echo "Development mode initialization already done. Skipping dev setup."
fi

View file

@ -0,0 +1,892 @@
#!/bin/bash
#
# Integration test suite for TLS/mTLS in modular mode.
# Validates that Dispatcharr connects correctly to external PostgreSQL and
# Redis services using various TLS configurations.
#
# Prerequisites:
# - Docker Desktop (or Docker Engine) running
# - Internet access (pulls postgres:17, redis:latest)
# - ~10-15 minutes for a full run
#
# Usage:
# cd <repo_root>
# bash docker/tests/test-tls-postgres.sh [--skip-build] [--keep-on-fail] [scenario_name]
#
# Options:
# --skip-build Skip Docker image build (use existing dispatcharr:tls-test image)
# --keep-on-fail Don't clean up containers/volumes on failure (for debugging)
# scenario_name Run only the named scenario
#
# Scenarios:
# modular_mtls_no_password PG mTLS cert-only auth, no password
# modular_mtls_with_password PG mTLS + password auth combined
# modular_tls_server_only PG server-side TLS only (no client cert)
# modular_tls_key_permission PG mTLS with 0777 client key (Docker Desktop scenario)
# modular_no_tls_regression Non-TLS modular mode still works
# modular_pg_verify_full PG mTLS with verify-full (CN must match hostname)
# modular_redis_tls Redis with TLS (server-side verification)
# modular_full_tls_celery PG mTLS + Redis TLS with separate Celery container
#
# Exit codes:
# 0 All tests passed
# 1 One or more tests failed (or build failed)
set -uo pipefail
# Prevent Git Bash (MINGW) from converting Unix paths
export MSYS_NO_PATHCONV=1
###############################################################################
# Configuration
###############################################################################
IMAGE_NAME="dispatcharr:tls-test"
TEST_PREFIX="tls_test"
STARTUP_TIMEOUT=120
SKIP_BUILD=false
KEEP_ON_FAIL=false
SINGLE_SCENARIO=""
PASS=0
FAIL=0
SKIP=0
ERRORS=()
CERT_DIR=""
# Colors (disabled if not a terminal)
if [ -t 1 ]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC=''
fi
###############################################################################
# Parse arguments
###############################################################################
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--keep-on-fail) KEEP_ON_FAIL=true ;;
-*) echo "Unknown option: $arg"; exit 1 ;;
*) SINGLE_SCENARIO="$arg" ;;
esac
done
###############################################################################
# Helpers
###############################################################################
CURRENT_SCENARIO=""
CLEANUP_ITEMS=()
log_pass() { echo -e " ${GREEN}$1${NC}"; PASS=$((PASS + 1)); }
log_fail() { echo -e " ${RED}$1${NC}"; FAIL=$((FAIL + 1)); ERRORS+=("[$CURRENT_SCENARIO] $1"); }
log_skip() { echo -e " ${YELLOW}⏭️ $1${NC}"; SKIP=$((SKIP + 1)); }
log_info() { echo -e " ${CYAN} $1${NC}"; }
section() { echo -e "\n${BOLD}━━━ $1 ━━━${NC}"; SCENARIO_FAIL_BEFORE=$FAIL; }
track_container() { CLEANUP_ITEMS+=("container:$1"); }
track_volume() { CLEANUP_ITEMS+=("volume:$1"); }
track_network() { CLEANUP_ITEMS+=("network:$1"); }
fresh_volume() {
local vol="$1"
docker rm -f $(docker ps -aq --filter "volume=${vol}") 2>/dev/null || true
docker volume rm "$vol" 2>/dev/null || true
docker volume create "$vol" >/dev/null
track_volume "$vol"
}
cleanup_scenario() {
if [ "$KEEP_ON_FAIL" = true ] && [ "$FAIL" -gt "${SCENARIO_FAIL_BEFORE:-0}" ]; then
log_info "Keeping resources for debugging (--keep-on-fail)"
CLEANUP_ITEMS=()
return
fi
for item in "${CLEANUP_ITEMS[@]}"; do
local type="${item%%:*}"
local name="${item#*:}"
case "$type" in
container) docker stop "$name" 2>/dev/null; docker rm -f "$name" 2>/dev/null ;;
volume) docker volume rm "$name" 2>/dev/null ;;
network) docker network rm "$name" 2>/dev/null ;;
esac
done
CLEANUP_ITEMS=()
}
trap 'cleanup_scenario; [ -n "$CERT_DIR" ] && rm -rf "$CERT_DIR"' EXIT
wait_for_ready() {
local name="$1"
local timeout="${2:-$STARTUP_TIMEOUT}"
local elapsed=0
while [ $elapsed -lt $timeout ]; do
if ! docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then
echo " Container $name exited unexpectedly"
return 1
fi
if docker logs "$name" 2>&1 | grep -q "uwsgi started with PID"; then
return 0
fi
sleep 3
((elapsed+=3))
done
echo " Timeout (${timeout}s) waiting for $name"
return 1
}
_capture_logs() {
local container="$1" logfile="$2"
docker logs "$container" > "$logfile" 2>&1
}
check_log_contains() {
local container="$1" pattern="$2" description="$3"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
if grep -q "$pattern" "$tmplog"; then
log_pass "$description"
else
log_fail "$description (pattern not found: $pattern)"
fi
rm -f "$tmplog"
}
check_log_absent() {
local container="$1" pattern="$2" description="$3"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
if grep -q "$pattern" "$tmplog"; then
log_fail "$description (unexpected pattern found: $pattern)"
else
log_pass "$description"
fi
rm -f "$tmplog"
}
check_migrations_done() {
local container="$1"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
if grep -qE "Running migrations|No migrations to apply|Operations to perform|Applying .+\.\.\. OK" "$tmplog"; then
log_pass "Django migrations completed"
elif grep -q "uwsgi started with PID" "$tmplog"; then
log_pass "Django migrations completed (confirmed via uwsgi startup)"
else
log_fail "Django migrations did not complete"
fi
rm -f "$tmplog"
}
check_no_permission_errors() {
local container="$1"
local tmplog; tmplog=$(mktemp)
_capture_logs "$container" "$tmplog"
local errors
errors=$(grep -iE "permission denied|operation not permitted" "$tmplog" \
| grep -v "GPU acceleration" | grep -v "Warning:" | head -5)
rm -f "$tmplog"
if [ -n "$errors" ]; then
log_fail "Permission errors in logs:"
echo "$errors" | while read -r line; do echo " $line"; done
else
log_pass "No permission errors in logs"
fi
}
dump_logs_on_fail() {
local container="$1"
if [ $FAIL -gt ${SCENARIO_FAIL_BEFORE:-0} ]; then
echo -e " ${YELLOW}--- Container logs ($container) ---${NC}"
docker logs "$container" 2>&1 | tail -30 | sed 's/^/ /'
echo -e " ${YELLOW}--- End logs ---${NC}"
fi
}
###############################################################################
# Certificate generation
###############################################################################
generate_test_certs() {
CERT_DIR=$(mktemp -d)
log_info "Generating test certificates in $CERT_DIR"
# Generate certs inside a container for cross-platform compatibility.
# Shared CA for both PG and Redis. CN of server certs must match their
# Docker container hostnames for verify-full mode.
docker run --rm --entrypoint sh \
-v "$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR"):/certs" \
-w /certs alpine/openssl -c '
# Shared CA
openssl req -new -x509 -days 1 -nodes \
-keyout ca.key -out ca.crt -subj "/CN=Test CA" 2>/dev/null &&
# PostgreSQL server cert (CN = PG container hostname)
openssl req -new -nodes \
-keyout pg-server.key -out pg-server.csr -subj "/CN='"${TEST_PREFIX}"'_pg" 2>/dev/null &&
openssl x509 -req -days 1 -in pg-server.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial -out pg-server.crt 2>/dev/null &&
# PostgreSQL client cert (CN = POSTGRES_USER)
openssl req -new -nodes \
-keyout pg-client.key -out pg-client.csr -subj "/CN=dispatch" 2>/dev/null &&
openssl x509 -req -days 1 -in pg-client.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial -out pg-client.crt 2>/dev/null &&
# Redis server cert (CN = Redis container hostname)
openssl req -new -nodes \
-keyout redis-server.key -out redis-server.csr -subj "/CN='"${TEST_PREFIX}"'_redis" 2>/dev/null &&
openssl x509 -req -days 1 -in redis-server.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial -out redis-server.crt 2>/dev/null &&
# Backwards-compat aliases (existing PG-only scenarios use these names)
cp pg-server.crt server.crt && cp pg-server.key server.key &&
cp pg-client.crt client.crt && cp pg-client.key client.key &&
chmod 600 pg-server.key pg-client.key redis-server.key server.key client.key
' || { log_fail "Certificate generation failed"; return 1; }
log_pass "Test certificates generated"
}
###############################################################################
# Start a TLS-enabled Redis container
###############################################################################
start_tls_redis() {
local name="$1" net="$2"
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
# Redis needs certs owned by redis user (uid 999 in the official image).
# Mount certs, copy to a writable location, fix ownership, then start
# with TLS flags.
docker run -d --name "$name" --network "$net" \
-v "${cert_mount}:/certs:ro" \
redis:latest \
sh -c '
cp /certs/redis-server.crt /certs/redis-server.key /certs/ca.crt /tmp/ &&
chmod 600 /tmp/redis-server.key &&
chown redis:redis /tmp/redis-server.crt /tmp/redis-server.key /tmp/ca.crt &&
exec redis-server \
--tls-port 6379 --port 0 \
--tls-cert-file /tmp/redis-server.crt \
--tls-key-file /tmp/redis-server.key \
--tls-ca-cert-file /tmp/ca.crt \
--tls-auth-clients no
' >/dev/null
# Wait for Redis TLS to be ready
local elapsed=0
while [ $elapsed -lt 20 ]; do
if docker exec "$name" redis-cli --tls \
--cert /certs/redis-server.crt --key /certs/redis-server.key --cacert /certs/ca.crt \
ping 2>/dev/null | grep -q "PONG"; then
break
fi
sleep 2; elapsed=$((elapsed + 2))
done
}
###############################################################################
# Start a TLS-enabled PostgreSQL container
###############################################################################
start_tls_postgres() {
local name="$1" net="$2" hba_auth="$3"
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
docker run -d --name "$name" --network "$net" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=tempsetup \
-e POSTGRES_DB=dispatcharr \
-v "${cert_mount}:/certs:ro" \
postgres:17 >/dev/null
# Wait for PG to initialize
local elapsed=0
while [ $elapsed -lt 30 ]; do
if docker exec "$name" su postgres -c "/usr/lib/postgresql/17/bin/pg_isready" 2>/dev/null | grep -q "accepting"; then
break
fi
sleep 2; ((elapsed+=2))
done
# Configure SSL and pg_hba.conf
docker exec "$name" bash -c "
cp /certs/server.crt /certs/server.key /certs/ca.crt /var/lib/postgresql/
chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key /var/lib/postgresql/ca.crt
chmod 600 /var/lib/postgresql/server.key
echo \"ssl = on\" >> /var/lib/postgresql/data/postgresql.conf
echo \"ssl_cert_file = '/var/lib/postgresql/server.crt'\" >> /var/lib/postgresql/data/postgresql.conf
echo \"ssl_key_file = '/var/lib/postgresql/server.key'\" >> /var/lib/postgresql/data/postgresql.conf
echo \"ssl_ca_file = '/var/lib/postgresql/ca.crt'\" >> /var/lib/postgresql/data/postgresql.conf
cat > /var/lib/postgresql/data/pg_hba.conf << HBA
local all all trust
hostssl all all 0.0.0.0/0 ${hba_auth}
hostssl all all ::0/0 ${hba_auth}
HBA
su postgres -c '/usr/lib/postgresql/17/bin/pg_ctl reload -D /var/lib/postgresql/data'
" >/dev/null 2>&1
sleep 1
}
###############################################################################
# Test scenarios
###############################################################################
test_modular_mtls_no_password() {
CURRENT_SCENARIO="modular_mtls_no_password"
section "Modular mode — mTLS cert-only auth (no password)"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
start_tls_postgres "$pg_name" "$net" "cert"
docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
# No POSTGRES_PASSWORD — cert-only auth
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-e POSTGRES_SSL=true \
-e POSTGRES_SSL_MODE=verify-ca \
-e POSTGRES_SSL_CA_CERT=/certs/ca.crt \
-e POSTGRES_SSL_CERT=/certs/client.crt \
-e POSTGRES_SSL_KEY=/certs/client.key \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started with mTLS cert-only auth"
check_log_contains "$name" "PostgreSQL version check passed" \
"Version check passed with mTLS"
check_log_contains "$name" "PostgreSQL TLS: enabled" \
"Django sees TLS enabled"
check_migrations_done "$name"
check_no_permission_errors "$name"
else
log_fail "Container failed to start with mTLS cert-only auth"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
test_modular_mtls_with_password() {
CURRENT_SCENARIO="modular_mtls_with_password"
section "Modular mode — mTLS + password auth"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
# cert + md5 password
start_tls_postgres "$pg_name" "$net" "cert"
docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=tempsetup \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-e POSTGRES_SSL=true \
-e POSTGRES_SSL_MODE=verify-ca \
-e POSTGRES_SSL_CA_CERT=/certs/ca.crt \
-e POSTGRES_SSL_CERT=/certs/client.crt \
-e POSTGRES_SSL_KEY=/certs/client.key \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started with mTLS + password"
check_log_contains "$name" "PostgreSQL version check passed" \
"Version check passed with mTLS + password"
check_migrations_done "$name"
else
log_fail "Container failed to start with mTLS + password"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
test_modular_tls_server_only() {
CURRENT_SCENARIO="modular_tls_server_only"
section "Modular mode — server-only TLS (no client cert)"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
# md5 auth over TLS (no client cert required)
start_tls_postgres "$pg_name" "$net" "md5"
docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=tempsetup \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-e POSTGRES_SSL=true \
-e POSTGRES_SSL_MODE=verify-ca \
-e POSTGRES_SSL_CA_CERT=/certs/ca.crt \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started with server-only TLS"
check_log_contains "$name" "PostgreSQL version check passed" \
"Version check passed with server-only TLS"
check_migrations_done "$name"
else
log_fail "Container failed to start with server-only TLS"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
test_modular_tls_key_permission() {
CURRENT_SCENARIO="modular_tls_key_permission"
section "Modular mode — mTLS with 0777 client key (Docker Desktop scenario)"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
start_tls_postgres "$pg_name" "$net" "cert"
docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null
# Create a copy of certs with 0777 key permissions
local bad_perms_dir
bad_perms_dir=$(mktemp -d)
cp "$CERT_DIR"/ca.crt "$CERT_DIR"/client.crt "$CERT_DIR"/client.key "$bad_perms_dir/"
chmod 777 "$bad_perms_dir/client.key"
local cert_mount
cert_mount="$(cygpath -w "$bad_perms_dir" 2>/dev/null || echo "$bad_perms_dir")"
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-e POSTGRES_SSL=true \
-e POSTGRES_SSL_MODE=verify-ca \
-e POSTGRES_SSL_CA_CERT=/certs/ca.crt \
-e POSTGRES_SSL_CERT=/certs/client.crt \
-e POSTGRES_SSL_KEY=/certs/client.key \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started with 0777 client key"
check_log_contains "$name" "Fixed PostgreSQL client key" \
"Key permission fix triggered"
check_log_contains "$name" "PostgreSQL version check passed" \
"Version check passed after key fix"
check_migrations_done "$name"
else
log_fail "Container failed to start with 0777 client key"
fi
dump_logs_on_fail "$name"
rm -rf "$bad_perms_dir"
cleanup_scenario
}
test_modular_no_tls_regression() {
CURRENT_SCENARIO="modular_no_tls_regression"
section "Modular mode — no TLS (regression check)"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
# Plain PostgreSQL — no TLS
docker run -d --name "$pg_name" --network "$net" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=dispatcharr \
postgres:17 >/dev/null
local elapsed=0
while [ $elapsed -lt 30 ]; do
if docker exec "$pg_name" su postgres -c "/usr/lib/postgresql/17/bin/pg_isready" 2>/dev/null | grep -q "accepting"; then
break
fi
sleep 2; ((elapsed+=2))
done
docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started without TLS (regression check)"
check_log_contains "$name" "PostgreSQL version check passed" \
"Version check passed without TLS"
check_log_absent "$name" "Fixed PostgreSQL client key" \
"No key fix when TLS disabled"
check_migrations_done "$name"
else
log_fail "Container failed to start without TLS"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
test_modular_pg_verify_full() {
CURRENT_SCENARIO="modular_pg_verify_full"
section "Modular mode — PG mTLS with verify-full (CN must match hostname)"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
start_tls_postgres "$pg_name" "$net" "cert"
docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
# verify-full requires server cert CN to match the hostname used to connect.
# Our PG server cert CN is "${TEST_PREFIX}_pg", which matches the container name
# used in POSTGRES_HOST.
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-e POSTGRES_SSL=true \
-e POSTGRES_SSL_MODE=verify-full \
-e POSTGRES_SSL_CA_CERT=/certs/ca.crt \
-e POSTGRES_SSL_CERT=/certs/client.crt \
-e POSTGRES_SSL_KEY=/certs/client.key \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started with verify-full"
check_log_contains "$name" "PostgreSQL version check passed" \
"Version check passed with verify-full"
check_log_contains "$name" "sslmode=verify-full" \
"Django reports verify-full mode"
check_migrations_done "$name"
else
log_fail "Container failed to start with verify-full"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
test_modular_redis_tls() {
CURRENT_SCENARIO="modular_redis_tls"
section "Modular mode — Redis with TLS"
local name="${TEST_PREFIX}_app"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"; track_container "$name"
# Plain PG (no TLS) — isolate Redis TLS testing
docker run -d --name "$pg_name" --network "$net" \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=dispatcharr \
postgres:17 >/dev/null
local elapsed=0
while [ $elapsed -lt 30 ]; do
if docker exec "$pg_name" su postgres -c "/usr/lib/postgresql/17/bin/pg_isready" 2>/dev/null | grep -q "accepting"; then
break
fi
sleep 2; elapsed=$((elapsed + 2))
done
start_tls_redis "$redis_name" "$net"
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
docker run -d --name "$name" --network "$net" \
-e DISPATCHARR_ENV=modular \
-e POSTGRES_HOST="$pg_name" \
-e POSTGRES_PORT=5432 \
-e POSTGRES_USER=dispatch \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=dispatcharr \
-e REDIS_HOST="$redis_name" \
-e REDIS_SSL=true \
-e REDIS_SSL_VERIFY=false \
-e REDIS_SSL_CA_CERT=/certs/ca.crt \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if wait_for_ready "$name"; then
log_pass "Container started with Redis TLS"
check_log_contains "$name" "Redis TLS: enabled" \
"Django reports Redis TLS enabled"
check_log_contains "$name" "Redis at ${redis_name}" \
"Redis connected via TLS"
check_migrations_done "$name"
else
log_fail "Container failed to start with Redis TLS"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
test_modular_full_tls_celery() {
CURRENT_SCENARIO="modular_full_tls_celery"
section "Modular mode — PG mTLS + Redis TLS with Celery container"
local name="${TEST_PREFIX}_app"
local celery_name="${TEST_PREFIX}_celery"
local pg_name="${TEST_PREFIX}_pg"
local redis_name="${TEST_PREFIX}_redis"
local net="${TEST_PREFIX}_net"
local vol="${name}_data"
cleanup_scenario
docker network create "$net" >/dev/null 2>&1
fresh_volume "$vol"
track_network "$net"
track_container "$pg_name"; track_container "$redis_name"
track_container "$name"; track_container "$celery_name"
start_tls_postgres "$pg_name" "$net" "cert"
start_tls_redis "$redis_name" "$net"
local cert_mount
cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")"
# Shared env vars for both web and celery containers
local -a tls_env=(
-e DISPATCHARR_ENV=modular
-e POSTGRES_HOST="$pg_name"
-e POSTGRES_PORT=5432
-e POSTGRES_USER=dispatch
-e POSTGRES_DB=dispatcharr
-e REDIS_HOST="$redis_name"
-e POSTGRES_SSL=true
-e POSTGRES_SSL_MODE=verify-ca
-e POSTGRES_SSL_CA_CERT=/certs/ca.crt
-e POSTGRES_SSL_CERT=/certs/client.crt
-e POSTGRES_SSL_KEY=/certs/client.key
-e REDIS_SSL=true
-e REDIS_SSL_VERIFY=false
-e REDIS_SSL_CA_CERT=/certs/ca.crt
)
# Start web container first (generates JWT, runs migrations)
docker run -d --name "$name" --network "$net" \
"${tls_env[@]}" \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
"$IMAGE_NAME" >/dev/null
if ! wait_for_ready "$name"; then
log_fail "Web container failed to start with full TLS"
dump_logs_on_fail "$name"
cleanup_scenario
return
fi
log_pass "Web container started with PG mTLS + Redis TLS"
# Start Celery container (shares /data volume for JWT, waits for migrations)
docker run -d --name "$celery_name" --network "$net" \
"${tls_env[@]}" \
-e DJANGO_SETTINGS_MODULE=dispatcharr.settings \
-e PYTHONUNBUFFERED=1 \
-v "${cert_mount}:/certs:ro" \
-v "${vol}:/data" \
--entrypoint /app/docker/entrypoint.celery.sh \
"$IMAGE_NAME" >/dev/null
# Wait for Celery to start (look for "starting Celery" message)
local elapsed=0
local celery_ok=false
while [ $elapsed -lt 90 ]; do
if ! docker ps -q -f "name=^${celery_name}$" 2>/dev/null | grep -q .; then
echo " Celery container exited unexpectedly"
break
fi
if docker logs "$celery_name" 2>&1 | grep -q "starting Celery"; then
celery_ok=true
break
fi
sleep 3; elapsed=$((elapsed + 3))
done
if [ "$celery_ok" = true ]; then
log_pass "Celery container started with PG mTLS + Redis TLS"
check_log_contains "$celery_name" "Migrations complete" \
"Celery confirmed migrations complete via TLS"
check_log_contains "$celery_name" "PostgreSQL TLS: enabled" \
"Celery sees PostgreSQL TLS enabled"
check_log_contains "$celery_name" "Redis TLS: enabled" \
"Celery sees Redis TLS enabled"
else
log_fail "Celery container failed to start with full TLS"
echo -e " ${YELLOW}--- Celery logs ---${NC}"
docker logs "$celery_name" 2>&1 | tail -20 | sed 's/^/ /'
echo -e " ${YELLOW}--- End logs ---${NC}"
fi
dump_logs_on_fail "$name"
cleanup_scenario
}
###############################################################################
# Main
###############################################################################
echo -e "${BOLD}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Dispatcharr — TLS Integration Tests ║${NC}"
echo -e "${BOLD}╚═══════════════════════════════════════════════════════════╝${NC}"
# Build image
if [ "$SKIP_BUILD" = false ]; then
echo -e "\n${BOLD}Building test image...${NC}"
if ! docker build -t "$IMAGE_NAME" -f docker/Dockerfile . 2>&1 | tail -5; then
echo -e "${RED}Build failed${NC}"
exit 1
fi
echo -e "${GREEN}Build complete${NC}"
else
echo -e "\n${YELLOW}Skipping build (--skip-build)${NC}"
fi
# Generate certificates
generate_test_certs || exit 1
# Run scenarios
SCENARIOS=(
modular_mtls_no_password
modular_mtls_with_password
modular_tls_server_only
modular_tls_key_permission
modular_no_tls_regression
modular_pg_verify_full
modular_redis_tls
modular_full_tls_celery
)
for scenario in "${SCENARIOS[@]}"; do
if [ -n "$SINGLE_SCENARIO" ] && [ "$scenario" != "$SINGLE_SCENARIO" ]; then
continue
fi
"test_${scenario}"
done
# Clean up certs
rm -rf "$CERT_DIR"
# Summary
echo -e "\n${BOLD}═══════════════════════════════════════════════════════════${NC}"
echo -e " ${GREEN}Passed: $PASS${NC} ${RED}Failed: $FAIL${NC} ${YELLOW}Skipped: $SKIP${NC}"
if [ ${#ERRORS[@]} -gt 0 ]; then
echo -e "\n ${RED}Failures:${NC}"
for err in "${ERRORS[@]}"; do
echo -e " ${RED}$err${NC}"
done
fi
echo -e "${BOLD}═══════════════════════════════════════════════════════════${NC}"
[ $FAIL -eq 0 ]

View file

@ -8,7 +8,7 @@
exec-pre = python /app/scripts/wait_for_redis.py
; Start Redis first
attach-daemon = redis-server
attach-daemon = redis-server --protected-mode no
; Then start other services with configurable nice level (default: 5 for low priority)
; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose
attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1
@ -57,4 +57,4 @@ logformat-strftime = true
log-date = %%Y-%%m-%%d %%H:%%M:%%S,000
# Use formatted time with environment variable for log level
log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms
log-buffering = 1024 # Add buffer size limit for logging
log-buffering = 1024 # Add buffer size limit for logging

View file

@ -2704,16 +2704,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
}
},
"node_modules/cac": {
@ -2850,9 +2850,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"engines": {
"node": ">= 6"
@ -3554,9 +3554,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@ -4362,9 +4362,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@ -5794,6 +5794,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -1601,9 +1601,7 @@ export default class API {
});
const playlist = await API.getPlaylist(accountId);
usePlaylistsStore
.getState()
.updateProfiles(playlist.id, playlist.profiles);
usePlaylistsStore.getState().updatePlaylist(playlist);
} catch (e) {
errorNotification(`Failed to update profile for account ${accountId}`, e);
}
@ -3000,12 +2998,15 @@ export default class API {
}
}
static async updateUser(id, body) {
static async updateUser(id, body, self = false) {
try {
const response = await request(`${host}/api/accounts/users/${id}/`, {
method: 'PATCH',
body,
});
const response = await request(
`${host}/api/accounts/users/${self ? 'me' : id}/`,
{
method: 'PATCH',
body,
}
);
useUsersStore.getState().updateUser(response);

View file

@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import useAuthStore from '../store/auth';
import mpegts from 'mpegts.js';
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
import {
@ -117,6 +118,7 @@ export default function FloatingVideo() {
const contentType = useVideoStore((s) => s.contentType);
const metadata = useVideoStore((s) => s.metadata);
const hideVideo = useVideoStore((s) => s.hideVideo);
const accessToken = useAuthStore((s) => s.accessToken);
const videoRef = useRef(null);
const playerRef = useRef(null);
@ -300,7 +302,8 @@ export default function FloatingVideo() {
setLoadError(null);
console.log('Initializing live stream player for:', streamUrl);
console.log('Access token present:', !!accessToken);
console.log('Current access token from auth store:', accessToken);
try {
if (!mpegts.getFeatureList().mseLivePlayback) {
setIsLoading(false);
@ -310,20 +313,34 @@ export default function FloatingVideo() {
return;
}
const player = mpegts.createPlayer({
type: 'mpegts',
url: streamUrl,
isLive: true,
enableWorker: true,
enableStashBuffer: false,
liveBufferLatencyChasing: true,
liveSync: true,
cors: true,
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 10,
autoCleanupMinBackwardDuration: 5,
reuseRedirectedURL: true,
});
// mpegts.js workers run in WorkerGlobalScope where relative URLs are
// not resolved against the page origin. Always pass an absolute URL.
const absoluteStreamUrl =
streamUrl.startsWith('/') && typeof window !== 'undefined'
? `${window.location.origin}${streamUrl}`
: streamUrl;
const player = mpegts.createPlayer(
{
type: 'mpegts',
url: absoluteStreamUrl,
isLive: true,
cors: true,
},
{
enableWorker: true,
enableStashBuffer: false,
liveBufferLatencyChasing: false,
liveSync: false,
autoCleanupSourceBuffer: true,
autoCleanupMaxBackwardDuration: 120,
autoCleanupMinBackwardDuration: 60,
reuseRedirectedURL: true,
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
}
);
player.attachMediaElement(videoRef.current);

View file

@ -5,7 +5,7 @@ import {
MINUTE_BLOCK_WIDTH,
MINUTE_INCREMENT,
PROGRAM_HEIGHT,
} from '../pages/guideUtils.js';
} from '../utils/guideUtils.js';
import { Box, Flex, Text, Tooltip } from '@mantine/core';
import { Play } from 'lucide-react';
import logo from '../images/logo.png';

View file

@ -1,7 +1,7 @@
import React from 'react';
import { Box, Text } from '@mantine/core';
import { format } from '../utils/dateTimeUtils.js';
import { HOUR_WIDTH } from '../pages/guideUtils.js';
import { HOUR_WIDTH } from '../utils/guideUtils.js';
const HourBlock = React.memo(
({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => {

View file

@ -16,7 +16,7 @@ import API from '../api';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils';
import { formatSeasonEpisode } from '../pages/guideUtils';
import { formatSeasonEpisode } from '../utils/guideUtils';
import {
format,
initializeTime,

View file

@ -1,12 +1,7 @@
import React, { useRef, useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
Copy,
LogOut,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Copy, LogOut, ChevronDown, ChevronRight, Heart } from 'lucide-react';
import { getOrderedNavItems } from '../config/navigation';
import {
Avatar,
@ -19,6 +14,7 @@ import {
ActionIcon,
AppShellNavbar,
ScrollArea,
Tooltip,
} from '@mantine/core';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
@ -29,6 +25,21 @@ import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
import NotificationCenter from './NotificationCenter';
const DonateButton = ({ tooltipPosition = 'top' }) => (
<Tooltip label="Support Dispatcharr" position={tooltipPosition}>
<ActionIcon
component="a"
href="https://opencollective.com/dispatcharr/contribute"
target="_blank"
rel="noopener noreferrer"
variant="transparent"
color="pink"
>
<Heart size={20} />
</ActionIcon>
</Tooltip>
);
const NavLink = ({ item, isActive, collapsed }) => {
const IconComponent = item.icon;
return (
@ -328,24 +339,56 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{!collapsed && (
<Group
gap="xs"
wrap="nowrap"
style={{ padding: '0 16px 16px', justifyContent: 'space-between' }}
>
<Text size="xs" c="dimmed">
v{appVersion?.version || '0.0.0'}
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
</Text>
{isAuthenticated && <NotificationCenter />}
<Tooltip
label={`v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`}
position="top"
>
<Text
size="xs"
c="dimmed"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
flex: 1,
cursor: 'pointer',
}}
onClick={() =>
copyToClipboard(
`v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`,
{
successTitle: 'Copied',
successMessage: 'Version copied to clipboard',
}
)
}
>
v{appVersion?.version || '0.0.0'}
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
</Text>
</Tooltip>
<Group gap="xs" wrap="nowrap">
<DonateButton />
{isAuthenticated && <NotificationCenter />}
</Group>
</Group>
)}
{collapsed && isAuthenticated && (
{collapsed && (
<Box
style={{
padding: '0 16px 16px',
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
}}
>
<NotificationCenter />
{isAuthenticated && <NotificationCenter />}
<DonateButton tooltipPosition="right" />
</Box>
)}

View file

@ -150,6 +150,10 @@ describe('FloatingVideo', () => {
type: 'mpegts',
url: 'http://example.com/stream.ts',
isLive: true,
}),
expect.objectContaining({
enableWorker: true,
enableStashBuffer: false,
})
);
});

View file

@ -6,7 +6,7 @@ import {
CHANNEL_WIDTH,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../../pages/guideUtils';
} from '../../utils/guideUtils';
// Mock logo import
vi.mock('../../images/logo.png', () => ({

View file

@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import HourTimeline from '../HourTimeline';
import { format } from '../../utils/dateTimeUtils';
import { HOUR_WIDTH } from '../../pages/guideUtils';
import { HOUR_WIDTH } from '../../utils/guideUtils';
// Mock date utilities
vi.mock('../../utils/dateTimeUtils', () => ({
@ -108,7 +108,9 @@ describe('HourTimeline', () => {
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
const hourBlocks = container.querySelectorAll(
'[style*="cursor: pointer"]'
);
hourBlocks.forEach((block) => {
expect(block).toHaveAttribute('w', `${HOUR_WIDTH}`);
expect(block).toHaveAttribute('h', '40px');
@ -126,16 +128,16 @@ describe('HourTimeline', () => {
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
const hourBlocks = container.querySelectorAll(
'[style*="cursor: pointer"]'
);
expect(hourBlocks[0]).toHaveStyle({
backgroundColor: '#1B2421',
});
});
it('should apply special styling for new day blocks', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const newDayTimeline = [{ time: mockTime3, isNewDay: true }];
const { container } = render(
<HourTimeline
@ -154,9 +156,7 @@ describe('HourTimeline', () => {
});
it('should apply bold font weight to day label on new day', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const newDayTimeline = [{ time: mockTime3, isNewDay: true }];
const { container } = render(
<HourTimeline
@ -198,7 +198,9 @@ describe('HourTimeline', () => {
/>
);
const markers = container.querySelectorAll('[style*="background-color: rgb(113, 128, 150);"]');
const markers = container.querySelectorAll(
'[style*="background-color: rgb(113, 128, 150);"]'
);
expect(markers.length).toBe(3); // 15, 30, 45 minute markers
});
@ -212,7 +214,9 @@ describe('HourTimeline', () => {
/>
);
const markers = container.querySelectorAll('[style*="backgroundColor: #718096"]');
const markers = container.querySelectorAll(
'[style*="backgroundColor: #718096"]'
);
const positions = ['25%', '50%', '75%'];
markers.forEach((marker, index) => {
@ -238,10 +242,15 @@ describe('HourTimeline', () => {
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
const hourBlocks = container.querySelectorAll(
'[style*="cursor: pointer"]'
);
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
expect(mockHandleTimeClick).toHaveBeenCalledWith(
mockTime1,
expect.any(Object)
);
});
it('should call handleTimeClick with correct time for each block', () => {
@ -254,13 +263,21 @@ describe('HourTimeline', () => {
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
const hourBlocks = container.querySelectorAll(
'[style*="cursor: pointer"]'
);
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
expect(mockHandleTimeClick).toHaveBeenCalledWith(
mockTime1,
expect.any(Object)
);
fireEvent.click(hourBlocks[1]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime2, expect.any(Object));
expect(mockHandleTimeClick).toHaveBeenCalledWith(
mockTime2,
expect.any(Object)
);
});
});

View file

@ -57,6 +57,7 @@ vi.mock('@mantine/core', async () => {
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: () => <div data-testid="icon-list-ordered" />,
CircleCheck: () => <div data-testid="circle-check-icon" />,
}));

View file

@ -33,8 +33,12 @@ vi.mock('@mantine/core', async () => {
{children}
</div>
),
PopoverTarget: ({ children }) => <div data-testid="popover-target">{children}</div>,
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
PopoverTarget: ({ children }) => (
<div data-testid="popover-target">{children}</div>
),
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
Indicator: ({ children, label, disabled, processing }) => (
<div
data-testid="indicator"
@ -46,18 +50,53 @@ vi.mock('@mantine/core', async () => {
</div>
),
ActionIcon: ({ children, onClick, 'aria-label': ariaLabel, ...props }) => (
<button onClick={onClick} aria-label={ariaLabel} data-testid={`action-icon-${ariaLabel}`} {...props}>
<button
onClick={onClick}
aria-label={ariaLabel}
data-testid={`action-icon-${ariaLabel}`}
{...props}
>
{children}
</button>
),
ScrollAreaAutosize: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Badge: ({ children, ...props }) => <span data-testid="badge" {...props}>{children}</span>,
Card: ({ children, ...props }) => <div data-testid="notification-card" {...props}>{children}</div>,
ThemeIcon: ({ children, ...props }) => <div data-testid="theme-icon" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Text: ({ children, ...props }) => <span data-testid="text" {...props}>{children}</span>,
ScrollAreaAutosize: ({ children }) => (
<div data-testid="scroll-area">{children}</div>
),
Badge: ({ children, ...props }) => (
<span data-testid="badge" {...props}>
{children}
</span>
),
Card: ({ children, ...props }) => (
<div data-testid="notification-card" {...props}>
{children}
</div>
),
ThemeIcon: ({ children, ...props }) => (
<div data-testid="theme-icon" {...props}>
{children}
</div>
),
Group: ({ children, ...props }) => (
<div data-testid="group" {...props}>
{children}
</div>
),
Stack: ({ children, ...props }) => (
<div data-testid="stack" {...props}>
{children}
</div>
),
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>
{children}
</div>
),
Text: ({ children, ...props }) => (
<span data-testid="text" {...props}>
{children}
</span>
),
Button: ({ children, onClick, ...props }) => (
<button onClick={onClick} data-testid="button" {...props}>
{children}
@ -84,14 +123,19 @@ vi.mock('@mantine/core', async () => {
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: () => <span data-testid="icon-list-ordered">ListOrdered</span>,
Bell: () => <span data-testid="bell-icon">Bell</span>,
Check: () => <span data-testid="check-icon">Check</span>,
CheckCheck: () => <span data-testid="checkcheck-icon">CheckCheck</span>,
Download: () => <span data-testid="download-icon">Download</span>,
ExternalLink: () => <span data-testid="external-link-icon">ExternalLink</span>,
ExternalLink: () => (
<span data-testid="external-link-icon">ExternalLink</span>
),
Info: () => <span data-testid="info-icon">Info</span>,
Settings: () => <span data-testid="settings-icon">Settings</span>,
AlertTriangle: () => <span data-testid="alert-triangle-icon">AlertTriangle</span>,
AlertTriangle: () => (
<span data-testid="alert-triangle-icon">AlertTriangle</span>
),
Megaphone: () => <span data-testid="megaphone-icon">Megaphone</span>,
X: () => <span data-testid="x-icon">X</span>,
Eye: () => <span data-testid="eye-icon">Eye</span>,
@ -279,8 +323,12 @@ describe('NotificationCenter', () => {
});
it('should handle API errors gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.getNotifications.mockRejectedValue(new Error('Network error'));
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
NotificationUtils.getNotifications.mockRejectedValue(
new Error('Network error')
);
renderComponent();
@ -311,7 +359,7 @@ describe('NotificationCenter', () => {
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
const toggleButton = eyeButtons.find((btn) =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
@ -376,7 +424,10 @@ describe('NotificationCenter', () => {
fireEvent.click(xIcons[0].closest('button'));
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(
1,
'dismissed'
);
});
});
@ -445,7 +496,10 @@ describe('NotificationCenter', () => {
fireEvent.click(applyButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'applied');
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(
2,
'applied'
);
expect(onSettingAction).toHaveBeenCalledWith(mockNotifications[1]);
});
});
@ -458,7 +512,10 @@ describe('NotificationCenter', () => {
fireEvent.click(ignoreButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'dismissed');
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(
2,
'dismissed'
);
});
});
@ -520,7 +577,7 @@ describe('NotificationCenter', () => {
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
const toggleButton = eyeButtons.find((btn) =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
@ -599,7 +656,7 @@ describe('NotificationCenter', () => {
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
const toggleButton = eyeButtons.find((btn) =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
@ -610,8 +667,12 @@ describe('NotificationCenter', () => {
describe('Error Handling', () => {
it('should handle dismiss notification errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissNotification.mockRejectedValue(new Error('API error'));
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
NotificationUtils.dismissNotification.mockRejectedValue(
new Error('API error')
);
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
@ -630,8 +691,12 @@ describe('NotificationCenter', () => {
});
it('should handle dismiss all notifications errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissAllNotifications.mockRejectedValue(new Error('API error'));
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
NotificationUtils.dismissAllNotifications.mockRejectedValue(
new Error('API error')
);
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
@ -655,7 +720,9 @@ describe('NotificationCenter', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const expectedDate = new Date('2024-01-01T10:00:00Z').toLocaleDateString();
const expectedDate = new Date(
'2024-01-01T10:00:00Z'
).toLocaleDateString();
expect(screen.getByText(expectedDate)).toBeInTheDocument();
});
});

View file

@ -21,9 +21,10 @@ vi.mock('../../utils/dateTimeUtils.js', () => ({
useDateTimeFormat: vi.fn(() => ({ timeFormat: 'h:mm A' })),
}));
vi.mock('../../pages/guideUtils', () => ({
vi.mock('../../utils/guideUtils', () => ({
formatSeasonEpisode: vi.fn((s, e) => {
if (s != null && e != null) return `S${String(s).padStart(2, '0')}E${String(e).padStart(2, '0')}`;
if (s != null && e != null)
return `S${String(s).padStart(2, '0')}E${String(e).padStart(2, '0')}`;
if (s != null) return `S${String(s).padStart(2, '0')}`;
if (e != null) return `E${String(e).padStart(2, '0')}`;
return null;

View file

@ -26,6 +26,7 @@ vi.mock('../../utils', () => ({
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: () => <div data-testid="icon-list-ordered" />,
Play: () => <div data-testid="play-icon" />,
Copy: () => <div data-testid="copy-icon" />,
}));
@ -40,27 +41,60 @@ vi.mock('@mantine/core', async () => {
if (!opened) return null;
return (
<div data-testid="modal" data-title={title} data-size={size}>
<button onClick={onClose} data-testid="modal-close">Close</button>
<button onClick={onClose} data-testid="modal-close">
Close
</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>
{children}
</div>
),
Button: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="button" {...props}>
<button
onClick={onClick}
disabled={disabled}
data-testid="button"
{...props}
>
{children}
</button>
),
Flex: ({ children, ...props }) => <div data-testid="flex" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Flex: ({ children, ...props }) => (
<div data-testid="flex" {...props}>
{children}
</div>
),
Group: ({ children, ...props }) => (
<div data-testid="group" {...props}>
{children}
</div>
),
Image: ({ src, alt, ...props }) => (
<img src={src} alt={alt} data-testid="image" {...props} />
),
Text: ({ children, ...props }) => <div data-testid="text" {...props}>{children}</div>,
Title: ({ children, order, ...props }) => (
<div data-testid="title" data-order={order} {...props}>{children}</div>
Text: ({ children, ...props }) => (
<div data-testid="text" {...props}>
{children}
</div>
),
Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
Title: ({ children, order, ...props }) => (
<div data-testid="title" data-order={order} {...props}>
{children}
</div>
),
Select: ({
value,
onChange,
data,
label,
placeholder,
disabled,
...props
}) => (
<div data-testid="select" data-label={label}>
<select
value={value || ''}
@ -77,39 +111,76 @@ vi.mock('@mantine/core', async () => {
</select>
</div>
),
Badge: ({ children, ...props }) => <a data-testid="badge" {...props}>{children}</a>,
Badge: ({ children, ...props }) => (
<a data-testid="badge" {...props}>
{children}
</a>
),
Loader: (props) => <div data-testid="loader" {...props} />,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
Stack: ({ children, ...props }) => (
<div data-testid="stack" {...props}>
{children}
</div>
),
ActionIcon: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="action-icon" {...props}>
<button
onClick={onClick}
disabled={disabled}
data-testid="action-icon"
{...props}
>
{children}
</button>
),
Tabs: ({ children, value, onChange, ...props }) => (
<div data-testid="tabs" data-value={value} {...props}>
<div onClick={(e) => {
const tab = e.target.closest('[data-tab-value]');
if (tab) onChange?.(tab.dataset.tabValue);
}}>
<div
onClick={(e) => {
const tab = e.target.closest('[data-tab-value]');
if (tab) onChange?.(tab.dataset.tabValue);
}}
>
{children}
</div>
</div>
),
TabsList: ({ children }) => <div data-testid="tabs-list">{children}</div>,
TabsTab: ({ children, value }) => (
<button data-testid="tabs-tab" data-tab-value={value}>{children}</button>
<button data-testid="tabs-tab" data-tab-value={value}>
{children}
</button>
),
TabsPanel: ({ children, value }) => (
<div data-testid="tabs-panel" data-value={value}>{children}</div>
<div data-testid="tabs-panel" data-value={value}>
{children}
</div>
),
Table: ({ children, ...props }) => (
<table data-testid="table" {...props}>
{children}
</table>
),
TableThead: ({ children }) => (
<thead data-testid="table-thead">{children}</thead>
),
TableTbody: ({ children }) => (
<tbody data-testid="table-tbody">{children}</tbody>
),
Table: ({ children, ...props }) => <table data-testid="table" {...props}>{children}</table>,
TableThead: ({ children }) => <thead data-testid="table-thead">{children}</thead>,
TableTbody: ({ children }) => <tbody data-testid="table-tbody">{children}</tbody>,
TableTr: ({ children, onClick, ...props }) => (
<tr onClick={onClick} data-testid="table-tr" {...props}>{children}</tr>
<tr onClick={onClick} data-testid="table-tr" {...props}>
{children}
</tr>
),
TableTh: ({ children, ...props }) => (
<th data-testid="table-th" {...props}>
{children}
</th>
),
TableTd: ({ children, ...props }) => (
<td data-testid="table-td" {...props}>
{children}
</td>
),
TableTh: ({ children, ...props }) => <th data-testid="table-th" {...props}>{children}</th>,
TableTd: ({ children, ...props }) => <td data-testid="table-td" {...props}>{children}</td>,
Divider: (props) => <hr data-testid="divider" {...props} />,
};
});
@ -168,7 +239,7 @@ describe('SeriesModal', () => {
m3u_account: { name: 'Provider 2' },
stream_name: 'Test Series 720p',
quality_info: null,
}
},
];
beforeEach(() => {
@ -187,9 +258,15 @@ describe('SeriesModal', () => {
environment: { env_mode: 'prod' },
};
useVODStore.mockImplementation((selector) => selector ? selector(mockVODStore) : mockVODStore);
useVideoStore.mockImplementation((selector) => selector ? selector(mockVideoStore) : mockVideoStore);
useSettingsStore.mockImplementation((selector) => selector ? selector(mockSettingsStore) : mockSettingsStore);
useVODStore.mockImplementation((selector) =>
selector ? selector(mockVODStore) : mockVODStore
);
useVideoStore.mockImplementation((selector) =>
selector ? selector(mockVideoStore) : mockVideoStore
);
useSettingsStore.mockImplementation((selector) =>
selector ? selector(mockSettingsStore) : mockSettingsStore
);
copyToClipboard.mockResolvedValue(undefined);
});
@ -325,23 +402,37 @@ describe('SeriesModal', () => {
it('should display IMDB link when imdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
<SeriesModal
series={mockDetailedSeries}
opened={true}
onClose={vi.fn()}
/>
);
await waitFor(() => {
const link = screen.getByText(/IMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
expect(link).toHaveAttribute(
'href',
'https://www.imdb.com/title/tt1234567'
);
});
});
it('should display TMDB link when tmdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
<SeriesModal
series={mockDetailedSeries}
opened={true}
onClose={vi.fn()}
/>
);
await waitFor(() => {
const link = screen.getByText(/TMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.themoviedb.org/tv/12345');
expect(link).toHaveAttribute(
'href',
'https://www.themoviedb.org/tv/12345'
);
});
});
});
@ -446,7 +537,12 @@ describe('SeriesModal', () => {
});
it('should sort episodes by episode number', async () => {
const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' };
const episode2 = {
...mockEpisode,
id: 2,
episode_number: 2,
name: 'Second Episode',
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode2, mockEpisode],
@ -592,7 +688,12 @@ describe('SeriesModal', () => {
describe('Season Tabs', () => {
it('should create tabs for each season', async () => {
const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 };
const season2Episode = {
...mockEpisode,
id: 2,
season_number: 2,
episode_num: 1,
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [mockEpisode, season2Episode],
@ -670,7 +771,6 @@ describe('SeriesModal', () => {
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
@ -797,7 +897,7 @@ describe('SeriesModal', () => {
);
await waitFor(() => {
expect(screen.getByText('Test Account')).toBeInTheDocument()
expect(screen.getByText('Test Account')).toBeInTheDocument();
});
});
});

View file

@ -56,6 +56,7 @@ vi.mock('lucide-react', () => ({
ChevronRight: () => <div data-testid="chevron-right-icon" />,
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
Heart: () => <div data-testid="heart-icon" />,
}));
// Mock UserForm component
@ -114,6 +115,7 @@ vi.mock('@mantine/core', async () => {
</nav>
),
ScrollArea: ({ children }) => <div>{children}</div>,
Tooltip: ({ children }) => <>{children}</>,
};
});

View file

@ -2,6 +2,7 @@ import { useLocation } from 'react-router-dom';
import React, { useEffect, useMemo, useState } from 'react';
import usePlaylistsStore from '../../store/playlists.jsx';
import useSettingsStore from '../../store/settings.jsx';
import useUsersStore from '../../store/users.jsx';
import {
ActionIcon,
Badge,
@ -123,6 +124,8 @@ const StreamConnectionCard = ({
// Get M3U account data from the playlists store
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
// Get users for resolving user_id username on client rows
const users = useUsersStore((s) => s.users);
// Get settings for speed threshold and environment mode
const settings = useSettingsStore((s) => s.settings);
const env_mode =
@ -138,6 +141,15 @@ const StreamConnectionCard = ({
return getM3uAccountsMap(m3uAccounts);
}, [m3uAccounts]);
// Create a map of user IDs to usernames for quick lookup
const usersMap = useMemo(() => {
const map = {};
users.forEach((u) => {
map[String(u.id)] = u.username;
});
return map;
}, [users]);
// Update M3U profile information when channel data changes
useEffect(() => {
// If the channel data includes M3U profile information, update our state
@ -314,7 +326,8 @@ const StreamConnectionCard = ({
{
header: 'IP Address',
accessorKey: 'ip_address',
size: 150,
grow: true,
minSize: 85,
cell: ({ cell }) => (
<Tooltip label={cell.getValue()}>
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
@ -323,10 +336,29 @@ const StreamConnectionCard = ({
</Tooltip>
),
},
{
id: 'user',
header: 'User',
grow: true,
minSize: 60,
accessorFn: (row) => {
const uid = row.user_id ? String(row.user_id) : null;
if (!uid || uid === '0') return 'Anonymous';
return usersMap[uid] || `User ${uid}`;
},
cell: ({ cell }) => (
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
{cell.getValue()}
</Text>
),
},
// Updated Connected column with tooltip
{
id: 'connected',
header: 'Connected',
grow: 1.5,
minSize: 70,
maxSize: 150,
accessorFn: connectedAccessor(fullDateTimeFormat),
cell: ({ cell }) => (
<Tooltip
@ -336,7 +368,9 @@ const StreamConnectionCard = ({
: 'Unknown connection time'
}
>
<Text size="xs">{cell.getValue()}</Text>
<Text size="xs" truncate style={{ maxWidth: '100%' }}>
{cell.getValue()}
</Text>
</Tooltip>
),
},
@ -344,6 +378,8 @@ const StreamConnectionCard = ({
{
id: 'duration',
header: 'Duration',
size: 82,
minSize: 60,
accessorFn: durationAccessor(),
cell: ({ cell, row }) => {
const exactDuration =
@ -356,7 +392,9 @@ const StreamConnectionCard = ({
: 'Unknown duration'
}
>
<Text size="xs">{cell.getValue()}</Text>
<Text size="xs" style={{ whiteSpace: 'nowrap' }}>
{cell.getValue()}
</Text>
</Tooltip>
);
},
@ -364,10 +402,11 @@ const StreamConnectionCard = ({
{
id: 'actions',
header: 'Actions',
size: 100,
size: 60,
minSize: 40,
},
],
[fullDateTimeFormat]
[fullDateTimeFormat, usersMap]
);
const channelClientsTable = useTable({
@ -383,6 +422,7 @@ const StreamConnectionCard = ({
}),
headerCellRenderFns: {
ip_address: renderHeaderCell,
user: renderHeaderCell,
connected: renderHeaderCell,
duration: renderHeaderCell,
actions: renderHeaderCell,
@ -610,8 +650,9 @@ const StreamConnectionCard = ({
{currentProgram &&
isProgramDescExpanded &&
currentProgram.start_time &&
currentProgram.end_time &&
<ProgramProgress currentProgram={currentProgram} />}
currentProgram.end_time && (
<ProgramProgress currentProgram={currentProgram} />
)}
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (

View file

@ -1,5 +1,5 @@
// Format duration for content length
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import logo from '../../images/logo.png';
import {
ActionIcon,
@ -38,6 +38,7 @@ import {
getMovieDisplayTitle,
getMovieSubtitle,
} from '../../utils/cards/VodConnectionCardUtils.js';
import useUsersStore from '../../store/users.jsx';
const ClientDetails = ({ connection, connectionStartTime }) => {
return (
@ -142,7 +143,10 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
};
const ConnectionProgress = ({ connection, durationSecs }) => {
const { totalTime, currentTime, percentage } = calculateProgress(connection, durationSecs);
const { totalTime, currentTime, percentage } = calculateProgress(
connection,
durationSecs
);
return totalTime > 0 ? (
<Stack gap="xs" mt="sm">
<Group justify="space-between" align="center">
@ -172,6 +176,14 @@ const ConnectionProgress = ({ connection, durationSecs }) => {
const VodConnectionCard = ({ vodContent, stopVODClient }) => {
const { fullDateTimeFormat } = useDateTimeFormat();
const [isClientExpanded, setIsClientExpanded] = useState(false);
const users = useUsersStore((s) => s.users);
const usersMap = useMemo(() => {
const map = {};
users.forEach((u) => {
map[String(u.id)] = u.username;
});
return map;
}, [users]);
const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates
// Get metadata from the VOD content
@ -377,13 +389,12 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
</Group>
{/* Progress bar - show current position in content */}
{connection &&
metadata.duration_secs &&
{connection && metadata.duration_secs && (
<ConnectionProgress
connection={connection}
durationSecs={metadata.duration_secs}
/>
}
)}
{/* Client information section - collapsible like channel cards */}
{connection && (
@ -403,11 +414,21 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => {
>
<Group gap={8}>
<Text size="sm" fw={500} color="dimmed">
Client:
Client IP:
</Text>
<Text size="sm" ff={'monospace'}>
{connection.client_ip || 'Unknown IP'}
</Text>
{usersMap[String(connection.user_id)] && (
<>
<Text size="sm" c="dimmed">
User:
</Text>
<Text size="sm">
{usersMap[String(connection.user_id)]}
</Text>
</>
)}
</Group>
<Group gap={8}>

View file

@ -109,13 +109,12 @@ vi.mock('@mantine/core', async () => ({
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
AlertTriangle: () => <svg data-testid="icon-alert-triangle" />,
Plus: () => <svg data-testid="icon-plus" />,
Square: () => <svg data-testid="icon-square" />,
@ -140,8 +139,13 @@ vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
import useChannelsStore from '../../../store/channels.jsx';
import useSettingsStore from '../../../store/settings.jsx';
import useVideoStore from '../../../store/useVideoStore.jsx';
import { useDateTimeFormat, useTimeHelpers, format, isAfter, isBefore }
from '../../../utils/dateTimeUtils.js';
import {
useDateTimeFormat,
useTimeHelpers,
format,
isAfter,
isBefore,
} from '../../../utils/dateTimeUtils.js';
import { notifications } from '@mantine/notifications';
import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
import dayjs from 'dayjs';
@ -187,7 +191,11 @@ const makeChannel = () => ({
});
/** Wire up all store/utility mocks with sensible defaults */
const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChannel() } = {}) => {
const setupMocks = ({
now = NOW,
recording = makeRecording(),
channel = makeChannel(),
} = {}) => {
const nowMoment = makeMoment(now);
const startMoment = makeMoment(recording.start_time);
const endMoment = makeMoment(recording.end_time);
@ -226,9 +234,13 @@ const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChan
vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue('/recordings/test.ts');
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(
'/recordings/test.ts'
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
vi.mocked(RecordingCardUtils.getSeriesInfo).mockReturnValue({ seriesId: 's1' });
vi.mocked(RecordingCardUtils.getSeriesInfo).mockReturnValue({
seriesId: 's1',
});
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
return { mockShowVideo, mockFetchRecordings };
@ -237,10 +249,18 @@ const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChan
describe('RecordingCard', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(RecordingCardUtils.stopRecordingById).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.deleteSeriesAndRule).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.extendRecordingById).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.stopRecordingById).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.deleteSeriesAndRule).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.extendRecordingById).mockResolvedValue(
undefined
);
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
vi.mocked(RecordingCardUtils.removeRecording).mockReturnValue(undefined);
});
@ -250,17 +270,23 @@ describe('RecordingCard', () => {
describe('rendering', () => {
it('renders the recording title', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Test Show')).toBeInTheDocument();
});
it('renders "Custom Recording" when no program title', () => {
setupMocks({
recording: makeRecording({ custom_properties: { status: 'completed', program: {} } }),
recording: makeRecording({
custom_properties: { status: 'completed', program: {} },
}),
});
render(
<RecordingCard
recording={makeRecording({ custom_properties: { status: 'completed', program: {} } })}
recording={makeRecording({
custom_properties: { status: 'completed', program: {} },
})}
channel={makeChannel()}
/>
);
@ -269,7 +295,9 @@ describe('RecordingCard', () => {
it('renders channel info', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('501 • HBO')).toBeInTheDocument();
});
@ -281,27 +309,35 @@ describe('RecordingCard', () => {
it('shows description via RecordingSynopsis for non-series completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByTestId('recording-synopsis')).toBeInTheDocument();
expect(screen.getByText('A test description')).toBeInTheDocument();
});
it('shows sub_title when present', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Pilot')).toBeInTheDocument();
});
it('shows season/episode label when getSeasonLabel returns a value', () => {
setupMocks();
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('S01E02')).toBeInTheDocument();
});
it('renders the poster image', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const img = screen.getByAltText('Test Show');
expect(img).toHaveAttribute('src', '/poster.jpg');
});
@ -312,7 +348,9 @@ describe('RecordingCard', () => {
describe('status badge', () => {
it('shows "Completed" badge for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Completed')).toBeInTheDocument();
});
@ -320,7 +358,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: { status: 'recording', program: { title: 'Live Show' } },
custom_properties: {
status: 'recording',
program: { title: 'Live Show' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -331,7 +372,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future Show' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future Show' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -373,8 +417,7 @@ describe('RecordingCard', () => {
// Series group
describe('series group', () => {
const makeSeriesRecording = () =>
makeRecording({ _group_count: 3 });
const makeSeriesRecording = () => makeRecording({ _group_count: 3 });
it('shows "Series" badge when _group_count > 1', () => {
const recording = makeSeriesRecording();
@ -394,7 +437,9 @@ describe('RecordingCard', () => {
const recording = makeSeriesRecording();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.queryByTestId('recording-synopsis')).not.toBeInTheDocument();
expect(
screen.queryByTestId('recording-synopsis')
).not.toBeInTheDocument();
});
});
@ -500,7 +545,9 @@ describe('RecordingCard', () => {
it('does not show "Watch Live" for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.queryByText('Watch Live')).not.toBeInTheDocument();
});
@ -523,7 +570,9 @@ describe('RecordingCard', () => {
it('shows "Watch" button for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Watch')).toBeInTheDocument();
});
@ -531,7 +580,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -540,7 +592,9 @@ describe('RecordingCard', () => {
it('calls showVideo with vod params when Watch is clicked', () => {
const { mockShowVideo } = setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Watch'));
expect(mockShowVideo).toHaveBeenCalledWith(
'/recordings/test.ts',
@ -552,7 +606,9 @@ describe('RecordingCard', () => {
it('does not call showVideo when Watch is clicked but no file url', () => {
const { mockShowVideo } = setupMocks();
vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(null);
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Watch'));
expect(mockShowVideo).not.toHaveBeenCalled();
});
@ -575,7 +631,9 @@ describe('RecordingCard', () => {
describe('"Remove commercials" button', () => {
it('shows "Remove commercials" for a completed recording without comskip', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Remove commercials')).toBeInTheDocument();
});
@ -597,7 +655,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -606,20 +667,31 @@ describe('RecordingCard', () => {
it('calls runComSkip and shows notification on success', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(makeRecording());
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
makeRecording()
);
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Removing commercials', color: 'blue.5' })
expect.objectContaining({
title: 'Removing commercials',
color: 'blue.5',
})
);
});
});
it('does not show notification when runComSkip throws', async () => {
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(new Error('fail'));
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
new Error('fail')
);
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(notifications.show).not.toHaveBeenCalled();
@ -634,7 +706,11 @@ describe('RecordingCard', () => {
makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: { status: 'recording', program: { title: 'Live Show' }, file_url: '/f.ts' },
custom_properties: {
status: 'recording',
program: { title: 'Live Show' },
file_url: '/f.ts',
},
});
it('shows extend menu for in-progress recording', () => {
@ -650,9 +726,15 @@ describe('RecordingCard', () => {
render(<RecordingCard recording={recording} channel={makeChannel()} />);
fireEvent.click(screen.getByText('+15 minutes'));
await waitFor(() => {
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 15);
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith(
'rec-1',
15
);
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recording extended', color: 'teal' })
expect.objectContaining({
title: 'Recording extended',
color: 'teal',
})
);
});
});
@ -663,7 +745,10 @@ describe('RecordingCard', () => {
render(<RecordingCard recording={recording} channel={makeChannel()} />);
fireEvent.click(screen.getByText('+30 minutes'));
await waitFor(() => {
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 30);
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith(
'rec-1',
30
);
});
});
@ -673,12 +758,17 @@ describe('RecordingCard', () => {
render(<RecordingCard recording={recording} channel={makeChannel()} />);
fireEvent.click(screen.getByText('+1 hour'));
await waitFor(() => {
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 60);
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith(
'rec-1',
60
);
});
});
it('shows error notification when extendRecordingById throws', async () => {
vi.mocked(RecordingCardUtils.extendRecordingById).mockRejectedValue(new Error('Network error'));
vi.mocked(RecordingCardUtils.extendRecordingById).mockRejectedValue(
new Error('Network error')
);
const recording = makeInProgress();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -698,7 +788,11 @@ describe('RecordingCard', () => {
makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: { status: 'recording', program: { title: 'Live Show' }, file_url: '/f.ts' },
custom_properties: {
status: 'recording',
program: { title: 'Live Show' },
file_url: '/f.ts',
},
});
it('shows stop modal when stop button is clicked', () => {
@ -711,7 +805,9 @@ describe('RecordingCard', () => {
fireEvent.click(stopButton);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stop Recording');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Stop Recording'
);
});
it('closes stop modal when Go Back is clicked', () => {
@ -736,7 +832,9 @@ describe('RecordingCard', () => {
fireEvent.click(screen.getAllByText('Stop Recording')[1]);
await waitFor(() => {
expect(RecordingCardUtils.stopRecordingById).toHaveBeenCalledWith('rec-1');
expect(RecordingCardUtils.stopRecordingById).toHaveBeenCalledWith(
'rec-1'
);
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
@ -761,48 +859,71 @@ describe('RecordingCard', () => {
describe('delete recording', () => {
it('shows delete modal for a completed non-series recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Recording');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Delete Recording'
);
});
it('shows "Cancel Recording" title for upcoming recording delete', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future Show' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future Show' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Cancel Recording');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Cancel Recording'
);
});
it('calls removeRecording when delete is confirmed', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(RecordingCardUtils.removeRecording).toHaveBeenCalledWith('rec-1');
expect(RecordingCardUtils.removeRecording).toHaveBeenCalledWith(
'rec-1'
);
});
});
it('closes delete modal after confirming', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Delete'));
@ -813,9 +934,13 @@ describe('RecordingCard', () => {
it('closes delete modal on Go Back click', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Go Back'));
@ -842,10 +967,14 @@ describe('RecordingCard', () => {
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Cancel Series');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Cancel Series'
);
});
it('calls deleteRecordingById when "Only this upcoming" is clicked', async () => {
@ -853,12 +982,16 @@ describe('RecordingCard', () => {
const { mockFetchRecordings } = setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Only this upcoming'));
await waitFor(() => {
expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith('rec-1');
expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
'rec-1'
);
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
@ -868,7 +1001,9 @@ describe('RecordingCard', () => {
const { mockFetchRecordings } = setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Entire series + rule'));
@ -883,7 +1018,9 @@ describe('RecordingCard', () => {
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Only this upcoming'));
@ -918,13 +1055,18 @@ describe('RecordingCard', () => {
_group_count: 3,
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Series' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Series' },
},
});
const { mockFetchRecordings } = setupMocks({ recording });
mockFetchRecordings.mockRejectedValue(new Error('network'));
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
await expect(
@ -932,4 +1074,4 @@ describe('RecordingCard', () => {
).resolves.not.toThrow();
});
});
});
});

View file

@ -30,7 +30,13 @@ vi.mock('@mantine/core', async () => ({
),
Stack: ({ children, gap }) => <div data-testid="stack">{children}</div>,
Text: ({ children, size, fw, c, lineClamp, style }) => (
<span data-testid="text" data-size={size} data-fw={fw} data-color={c} style={style}>
<span
data-testid="text"
data-size={size}
data-fw={fw}
data-color={c}
style={style}
>
{children}
</span>
),
@ -38,6 +44,7 @@ vi.mock('@mantine/core', async () => ({
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Calendar: () => <svg data-testid="icon-calendar" />,
Play: () => <svg data-testid="icon-play" />,
Star: () => <svg data-testid="icon-star" />,
@ -78,7 +85,12 @@ describe('SeriesCard', () => {
});
it('renders a fallback image when poster_url is missing', () => {
render(<SeriesCard series={makeSeries({ poster_url: null })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ poster_url: null })}
onClick={vi.fn()}
/>
);
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
});
@ -105,7 +117,12 @@ describe('SeriesCard', () => {
it('renders play icon', () => {
//this only renders when logo.url is missing, but we want to test that the icon itself renders correctly
render(<SeriesCard series={makeSeries({ logo: { url: null } })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ logo: { url: null } })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('icon-play')).toBeInTheDocument();
});
@ -119,27 +136,52 @@ describe('SeriesCard', () => {
describe('optional fields', () => {
it('does not crash when year is missing', () => {
render(<SeriesCard series={makeSeries({ year: undefined })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ year: undefined })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when rating is missing', () => {
render(<SeriesCard series={makeSeries({ rating: undefined })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ rating: undefined })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when genre is missing', () => {
render(<SeriesCard series={makeSeries({ genre: undefined })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ genre: undefined })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when description is missing', () => {
render(<SeriesCard series={makeSeries({ description: undefined })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ description: undefined })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when seasons is missing', () => {
render(<SeriesCard series={makeSeries({ seasons: undefined })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ seasons: undefined })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
@ -161,4 +203,4 @@ describe('SeriesCard', () => {
expect(onClick).toHaveBeenCalledWith(series);
});
});
});
});

View file

@ -95,7 +95,12 @@ vi.mock('@mantine/core', () => ({
Center: ({ children }) => <div data-testid="center">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
Progress: ({ value, size, color }) => (
<div data-testid="progress" data-value={value} data-size={size} data-color={color} />
<div
data-testid="progress"
data-value={value}
data-size={size}
data-color={color}
/>
),
Select: ({ value, onChange, label, data, disabled, placeholder }) => (
<select
@ -129,9 +134,7 @@ vi.mock('@mantine/core', () => ({
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
useMantineTheme: vi.fn(() => ({
tailwind: { green: { 5: '#22c55e' } },
})),
@ -139,6 +142,22 @@ vi.mock('@mantine/core', () => ({
// lucide-react
vi.mock('lucide-react', () => ({
// navigation.js icons (all must be present for the authnavigation import chain)
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Play: () => <svg data-testid="icon-play" />,
Database: () => <svg data-testid="icon-database" />,
LayoutGrid: () => <svg data-testid="icon-layout-grid" />,
Settings: () => <svg data-testid="icon-settings" />,
ChartLine: () => <svg data-testid="icon-chart-line" />,
Video: () => <svg data-testid="icon-video" />,
PlugZap: () => <svg data-testid="icon-plug-zap" />,
User: () => <svg data-testid="icon-user" />,
FileImage: () => <svg data-testid="icon-file-image" />,
Webhook: () => <svg data-testid="icon-webhook" />,
Logs: () => <svg data-testid="icon-logs" />,
Blocks: () => <svg data-testid="icon-blocks" />,
MonitorCog: () => <svg data-testid="icon-monitor-cog" />,
// StreamConnectionCard-specific icons
ChevronDown: () => <svg data-testid="icon-chevron-down" />,
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
CirclePlay: () => <svg data-testid="icon-circle-play" />,
@ -149,7 +168,6 @@ vi.mock('lucide-react', () => ({
SquareX: () => <svg data-testid="icon-square-x" />,
Timer: () => <svg data-testid="icon-timer" />,
Users: () => <svg data-testid="icon-users" />,
Video: () => <svg data-testid="icon-video" />,
}));
// Imports after mocks
@ -264,13 +282,17 @@ describe('StreamConnectionCard', () => {
describe('route guard', () => {
it('renders nothing when pathname is not /stats', () => {
setupLocation('/dashboard');
const { container } = render(<StreamConnectionCard {...defaultProps()} />);
const { container } = render(
<StreamConnectionCard {...defaultProps()} />
);
expect(container.firstChild).toBeNull();
});
it('renders nothing when pathname is /channels', () => {
setupLocation('/channels');
const { container } = render(<StreamConnectionCard {...defaultProps()} />);
const { container } = render(
<StreamConnectionCard {...defaultProps()} />
);
expect(container.firstChild).toBeNull();
});
@ -337,7 +359,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(getStreamsByIds).mockResolvedValue([]);
render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ name: '', stream_id: null }) })}
{...defaultProps({
channel: makeChannel({ name: '', stream_id: null }),
})}
/>
);
expect(screen.getByText('Unnamed Channel')).toBeInTheDocument();
@ -465,7 +489,9 @@ describe('StreamConnectionCard', () => {
describe('current program', () => {
it('does not render program section when currentProgram is null', () => {
render(<StreamConnectionCard {...defaultProps({ currentProgram: null })} />);
render(
<StreamConnectionCard {...defaultProps({ currentProgram: null })} />
);
expect(screen.queryByText('Now Playing:')).not.toBeInTheDocument();
});
@ -493,7 +519,9 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument();
expect(
screen.queryByText('Daily news broadcast.')
).not.toBeInTheDocument();
});
it('expands program description when chevron button is clicked', () => {
@ -502,7 +530,9 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.getByText('Daily news broadcast.')).toBeInTheDocument();
});
@ -513,12 +543,18 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.getByText('Daily news broadcast.')).toBeInTheDocument();
const chevronDownBtn = screen.getByTestId('icon-chevron-down').closest('button');
const chevronDownBtn = screen
.getByTestId('icon-chevron-down')
.closest('button');
fireEvent.click(chevronDownBtn);
expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument();
expect(
screen.queryByText('Daily news broadcast.')
).not.toBeInTheDocument();
});
it('renders program progress when expanded and times are present', () => {
@ -527,7 +563,9 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.getByTestId('progress')).toBeInTheDocument();
});
@ -536,11 +574,16 @@ describe('StreamConnectionCard', () => {
render(
<StreamConnectionCard
{...defaultProps({
currentProgram: makeCurrentProgram({ start_time: null, end_time: null }),
currentProgram: makeCurrentProgram({
start_time: null,
end_time: null,
}),
})}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
});
@ -570,9 +613,8 @@ describe('StreamConnectionCard', () => {
{ id: 11, name: 'Stream B', url: 'http://b.com', m3u_profile: null },
]);
// Provide options via getStreamOptions mock
const { getStreamOptions } = await import(
'../../../utils/cards/StreamConnectionCardUtils.js'
);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
{ value: '11', label: 'Stream B' },
@ -585,7 +627,12 @@ describe('StreamConnectionCard', () => {
it('sets activeStreamId when a matching stream is found by URL', async () => {
vi.mocked(getChannelStreams).mockResolvedValue([
{ id: 42, name: 'Stream A', url: 'http://stream.example.com/ch1', m3u_profile: null },
{
id: 42,
name: 'Stream A',
url: 'http://stream.example.com/ch1',
m3u_profile: null,
},
]);
vi.mocked(getMatchingStreamByUrl).mockReturnValue({
id: 42,
@ -614,11 +661,15 @@ describe('StreamConnectionCard', () => {
describe('stream switching', () => {
beforeEach(async () => {
vi.mocked(getChannelStreams).mockResolvedValue([
{ id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: { name: 'M3U A' } },
{
id: 10,
name: 'Stream A',
url: 'http://a.com',
m3u_profile: { name: 'M3U A' },
},
]);
const { getStreamOptions } = await import(
'../../../utils/cards/StreamConnectionCardUtils.js'
);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
]);
@ -633,7 +684,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(switchStream).mockResolvedValue({});
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(switchStream).toHaveBeenCalledWith(
expect.objectContaining({ channel_id: 'ch-uuid-1' }),
@ -646,7 +699,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(switchStream).mockResolvedValue({});
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'blue.5' })
@ -658,7 +713,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(switchStream).mockRejectedValue(new Error('Switch failed'));
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red.5' })
@ -672,7 +729,9 @@ describe('StreamConnectionCard', () => {
});
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(screen.getByText('Updated M3U')).toBeInTheDocument();
});
@ -686,7 +745,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(getChannelStreams).mockResolvedValue([]);
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => {
expect(screen.queryByTestId('icon-circle-play')).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-circle-play')
).not.toBeInTheDocument();
});
});
@ -694,10 +755,11 @@ describe('StreamConnectionCard', () => {
vi.mocked(getChannelStreams).mockResolvedValue([
{ id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null },
]);
const { getStreamOptions } = await import(
'../../../utils/cards/StreamConnectionCardUtils.js'
);
vi.mocked(getStreamOptions).mockReturnValue([{ value: '10', label: 'Stream A' }]);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
]);
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => {
expect(screen.getByTestId('icon-circle-play')).toBeInTheDocument();
@ -712,10 +774,11 @@ describe('StreamConnectionCard', () => {
vi.mocked(getChannelStreams).mockResolvedValue([
{ id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null },
]);
const { getStreamOptions } = await import(
'../../../utils/cards/StreamConnectionCardUtils.js'
);
vi.mocked(getStreamOptions).mockReturnValue([{ value: '10', label: 'Stream A' }]);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
]);
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('icon-circle-play'));
@ -738,10 +801,11 @@ describe('StreamConnectionCard', () => {
vi.mocked(getChannelStreams).mockResolvedValue([
{ id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null },
]);
const { getStreamOptions } = await import(
'../../../utils/cards/StreamConnectionCardUtils.js'
);
vi.mocked(getStreamOptions).mockReturnValue([{ value: '10', label: 'Stream A' }]);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
]);
render(
<StreamConnectionCard {...defaultProps({ channelsByUUID: {} })} />
@ -837,4 +901,4 @@ describe('StreamConnectionCard', () => {
expect(screen.getAllByText('0 Kbps').length).toBeGreaterThan(0);
});
});
});
});

View file

@ -10,12 +10,22 @@ vi.mock('../../../utils/cards/VODCardUtils.js', () => ({
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, variant, size }) => (
<button data-testid="action-icon" data-variant={variant} data-size={size} onClick={onClick}>
<button
data-testid="action-icon"
data-variant={variant}
data-size={size}
onClick={onClick}
>
{children}
</button>
),
Badge: ({ children, color, variant, size }) => (
<span data-testid="badge" data-color={color} data-variant={variant} data-size={size}>
<span
data-testid="badge"
data-color={color}
data-variant={variant}
data-size={size}
>
{children}
</span>
),
@ -37,14 +47,27 @@ vi.mock('@mantine/core', () => ({
{children}
</div>
),
CardSection: ({ children }) => <div data-testid="card-section">{children}</div>,
CardSection: ({ children }) => (
<div data-testid="card-section">{children}</div>
),
Group: ({ children, justify, gap, wrap }) => (
<div data-testid="group" data-justify={justify} data-gap={gap} data-wrap={wrap}>
<div
data-testid="group"
data-justify={justify}
data-gap={gap}
data-wrap={wrap}
>
{children}
</div>
),
Image: ({ src, alt, height, fallbackSrc, fit }) => (
<img src={src} alt={alt} data-height={height} data-fallback={fallbackSrc} data-fit={fit} />
<img
src={src}
alt={alt}
data-height={height}
data-fallback={fallbackSrc}
data-fit={fit}
/>
),
Stack: ({ children, spacing, gap, p }) => (
<div data-testid="stack" data-spacing={spacing} data-gap={gap} data-p={p}>
@ -68,6 +91,7 @@ vi.mock('@mantine/core', () => ({
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Calendar: () => <svg data-testid="icon-calendar" />,
Clock: () => <svg data-testid="icon-clock" />,
Play: () => <svg data-testid="icon-play" />,
@ -75,7 +99,10 @@ vi.mock('lucide-react', () => ({
}));
// Imports after mocks
import { formatDuration, getSeasonLabel } from '../../../utils/cards/VODCardUtils.js';
import {
formatDuration,
getSeasonLabel,
} from '../../../utils/cards/VODCardUtils.js';
import VODCard from '../VODCard';
// Helpers
@ -113,7 +140,9 @@ const makeEpisode = (overrides = {}) => ({
describe('VODCard', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(formatDuration).mockImplementation((mins) => (mins ? `${mins}m` : null));
vi.mocked(formatDuration).mockImplementation((mins) =>
mins ? `${mins}m` : null
);
vi.mocked(getSeasonLabel).mockReturnValue('S01E02');
});
@ -193,7 +222,9 @@ describe('VODCard', () => {
it('renders the season label via getSeasonLabel', () => {
render(<VODCard vod={makeEpisode()} onClick={vi.fn()} />);
expect(getSeasonLabel).toHaveBeenCalledWith(expect.objectContaining({ type: 'episode' }));
expect(getSeasonLabel).toHaveBeenCalledWith(
expect.objectContaining({ type: 'episode' })
);
expect(screen.getByText(/S01E02/)).toBeInTheDocument();
});
@ -230,7 +261,9 @@ describe('VODCard', () => {
});
it('does not render an img tag when logo.url is empty string', () => {
render(<VODCard vod={makeMovie({ logo: { url: '' } })} onClick={vi.fn()} />);
render(
<VODCard vod={makeMovie({ logo: { url: '' } })} onClick={vi.fn()} />
);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
});
@ -257,8 +290,9 @@ describe('VODCard', () => {
it('does not render genre when absent', () => {
render(<VODCard vod={makeMovie({ genre: null })} onClick={vi.fn()} />);
const badges = screen.queryAllByTestId('badge');
const dimmedBadges = badges.filter((badge) =>
badge.getAttribute('data-color') === 'dimmed');
const dimmedBadges = badges.filter(
(badge) => badge.getAttribute('data-color') === 'dimmed'
);
expect(dimmedBadges.length).toBe(0);
});
});
@ -291,4 +325,4 @@ describe('VODCard', () => {
expect(onClick).toHaveBeenCalledWith(vod);
});
});
});
});

View file

@ -13,7 +13,11 @@ vi.mock('../../../utils/dateTimeUtils.js', () => ({
vi.mock('../../../utils/cards/VodConnectionCardUtils.js', () => ({
calculateConnectionDuration: vi.fn(() => '5m 30s'),
calculateConnectionStartTime: vi.fn(() => 'Jan 1 2024 10:00 AM'),
calculateProgress: vi.fn(() => ({ totalTime: 0, currentTime: 0, percentage: 0 })),
calculateProgress: vi.fn(() => ({
totalTime: 0,
currentTime: 0,
percentage: 0,
})),
formatDuration: vi.fn((secs) => (secs ? `${secs}s` : null)),
formatTime: vi.fn((secs) => `${secs}s`),
getEpisodeDisplayTitle: vi.fn(() => 'S01E02 — Pilot'),
@ -28,42 +32,82 @@ vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, color, variant }) => (
<button data-testid="action-icon" data-color={color} data-variant={variant} onClick={onClick}>
<button
data-testid="action-icon"
data-color={color}
data-variant={variant}
onClick={onClick}
>
{children}
</button>
),
Badge: ({ children, color, variant, size }) => (
<span data-testid="badge" data-color={color} data-variant={variant} data-size={size}>
<span
data-testid="badge"
data-color={color}
data-variant={variant}
data-size={size}
>
{children}
</span>
),
Box: ({ children, style }) => <div data-testid="box" style={style}>{children}</div>,
Card: ({ children, shadow, style }) => ( // remove padding, radius, withBorder
Box: ({ children, style }) => (
<div data-testid="box" style={style}>
{children}
</div>
),
Card: (
{ children, shadow, style } // remove padding, radius, withBorder
) => (
<div data-testid="vod-connection-card" data-shadow={shadow} style={style}>
{children}
</div>
),
Center: ({ children }) => <div data-testid="center">{children}</div>,
Flex: ({ children, justify }) => ( // remove align
<div data-testid="flex" data-justify={justify}>{children}</div>
Flex: (
{ children, justify } // remove align
) => (
<div data-testid="flex" data-justify={justify}>
{children}
</div>
),
Group: ({ children, justify, onClick, style }) => ( // remove gap, align, p
<div data-testid="group" data-justify={justify} onClick={onClick} style={style}>
Group: (
{ children, justify, onClick, style } // remove gap, align, p
) => (
<div
data-testid="group"
data-justify={justify}
onClick={onClick}
style={style}
>
{children}
</div>
),
Progress: ({ value, size, color }) => (
<div data-testid="progress" data-value={value} data-size={size} data-color={color} />
<div
data-testid="progress"
data-value={value}
data-size={size}
data-color={color}
/>
),
Stack: ({ children, gap, pos, mt }) => (
<div data-testid="stack" data-gap={gap} data-pos={pos} data-mt={mt}>
{children}
</div>
),
Text: ({ children, size, c, fw, color }) => ( // remove ff, ta
<span data-testid="text" data-size={size} data-c={c} data-fw={fw} data-color={color}>
{children}
</span>
Text: (
{ children, size, c, fw, color } // remove ff, ta
) => (
<span
data-testid="text"
data-size={size}
data-c={c}
data-fw={fw}
data-color={color}
>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-label={label}>
@ -72,15 +116,35 @@ vi.mock('@mantine/core', () => ({
),
}));
// useUsersStore
vi.mock('../../../store/users.jsx', () => ({
default: vi.fn((selector) => selector({ users: [] })),
}));
// lucide-react
vi.mock('lucide-react', () => ({
// navigation.js icons (all must be present for the authnavigation import chain)
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Play: () => <svg data-testid="icon-play" />,
Database: () => <svg data-testid="icon-database" />,
LayoutGrid: () => <svg data-testid="icon-layout-grid" />,
Settings: () => <svg data-testid="icon-settings" />,
ChartLine: () => <svg data-testid="icon-chart-line" />,
Video: () => <svg data-testid="icon-video" />,
PlugZap: () => <svg data-testid="icon-plug-zap" />,
User: () => <svg data-testid="icon-user" />,
FileImage: () => <svg data-testid="icon-file-image" />,
Webhook: () => <svg data-testid="icon-webhook" />,
Logs: () => <svg data-testid="icon-logs" />,
Blocks: () => <svg data-testid="icon-blocks" />,
MonitorCog: () => <svg data-testid="icon-monitor-cog" />,
// VodConnectionCard-specific icons
ChevronDown: ({ size, style }) => (
<svg data-testid="icon-chevron-down" data-size={size} style={style} />
),
HardDriveUpload: () => <svg data-testid="icon-hdd-upload" />,
SquareX: () => <svg data-testid="icon-square-x" />,
Timer: () => <svg data-testid="icon-timer" />,
Video: () => <svg data-testid="icon-video" />,
}));
// Imports after mocks
@ -100,12 +164,13 @@ import VodConnectionCard from '../VodConnectionCard';
const makeConnection = (overrides = {}) => ({
client_ip: '192.168.1.100',
client_id: 'client-abc-123', // needed for stopVODClient
client_id: 'client-abc-123', // needed for stopVODClient
user_agent: 'Plex/1.0',
connected_at: '2024-01-01T10:00:00Z',
duration: 330,
bytes_sent: 1048576,
m3u_profile: { // M3U profile lives on connection
m3u_profile: {
// M3U profile lives on connection
account_name: 'Main Account',
profile_name: 'Main M3U',
},
@ -161,14 +226,22 @@ describe('VodConnectionCard', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(useDateTimeFormat).mockReturnValue({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' });
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 0, currentTime: 0, percentage: 0 });
vi.mocked(useDateTimeFormat).mockReturnValue({
fullDateTimeFormat: 'MM/DD/YYYY h:mm A',
});
vi.mocked(calculateProgress).mockReturnValue({
totalTime: 0,
currentTime: 0,
percentage: 0,
});
vi.mocked(getMovieDisplayTitle).mockReturnValue('Test Movie (2022)');
vi.mocked(getMovieSubtitle).mockReturnValue(['120m', 'Action']);
vi.mocked(getEpisodeDisplayTitle).mockReturnValue('S01E02 — Pilot');
vi.mocked(getEpisodeSubtitle).mockReturnValue(['Test Series', 'Season 1']);
vi.mocked(calculateConnectionDuration).mockReturnValue('5m 30s');
vi.mocked(calculateConnectionStartTime).mockReturnValue('Jan 1 2024 10:00 AM');
vi.mocked(calculateConnectionStartTime).mockReturnValue(
'Jan 1 2024 10:00 AM'
);
});
afterEach(() => {
@ -179,39 +252,69 @@ describe('VodConnectionCard', () => {
describe('rendering', () => {
it('renders the card element', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByTestId('vod-connection-card')).toBeInTheDocument();
});
it('renders the poster image when logo_url is present', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://example.com/poster.jpg');
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'http://example.com/poster.jpg'
);
});
it('renders the default logo when logo_url is absent', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ content_metadata: { logo_url: null } })}
vodContent={makeMovieContent({
content_metadata: { logo_url: null },
})}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByRole('img')).toHaveAttribute('src', 'default-logo.png');
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'default-logo.png'
);
});
it('renders the stop client button', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByTestId('icon-square-x')).toBeInTheDocument();
});
it('renders the client IP in the connection section', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
});
it('renders "Unknown IP" when client_ip is absent', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: makeConnection({ client_ip: null }) })}
vodContent={makeMovieContent({
individual_connection: makeConnection({ client_ip: null }),
})}
stopVODClient={vi.fn()}
/>
);
@ -219,14 +322,22 @@ describe('VodConnectionCard', () => {
});
it('renders "Hide Details" / "Show Details" toggle text', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Show Details')).toBeInTheDocument();
});
it('does not render client section when no connection is present', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: null, connections: null })}
vodContent={makeMovieContent({
individual_connection: null,
connections: null,
})}
stopVODClient={vi.fn()}
/>
);
@ -239,29 +350,50 @@ describe('VodConnectionCard', () => {
describe('movie content', () => {
it('calls getMovieDisplayTitle with vodContent', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(getMovieDisplayTitle).toHaveBeenCalledWith(vodContent);
});
it('renders the movie display title', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Test Movie (2022)')).toBeInTheDocument();
});
it('calls getMovieSubtitle with metadata', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getMovieSubtitle).toHaveBeenCalledWith(vodContent.content_metadata);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(getMovieSubtitle).toHaveBeenCalledWith(
vodContent.content_metadata
);
});
it('renders the movie subtitle parts', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText(/120m/)).toBeInTheDocument();
expect(screen.getByText(/Action/)).toBeInTheDocument();
});
it('does not call getEpisodeDisplayTitle for a movie', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(getEpisodeDisplayTitle).not.toHaveBeenCalled();
});
});
@ -271,29 +403,52 @@ describe('VodConnectionCard', () => {
describe('episode content', () => {
it('calls getEpisodeDisplayTitle with vodContent', () => {
const vodContent = makeEpisodeContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getEpisodeDisplayTitle).toHaveBeenCalledWith(vodContent.content_metadata);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(getEpisodeDisplayTitle).toHaveBeenCalledWith(
vodContent.content_metadata
);
});
it('renders the episode display title', () => {
render(<VodConnectionCard vodContent={makeEpisodeContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeEpisodeContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('S01E02 — Pilot')).toBeInTheDocument();
});
it('calls getEpisodeSubtitle with metadata', () => {
const vodContent = makeEpisodeContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getEpisodeSubtitle).toHaveBeenCalledWith(vodContent.content_metadata);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(getEpisodeSubtitle).toHaveBeenCalledWith(
vodContent.content_metadata
);
});
it('renders the episode subtitle parts', () => {
render(<VodConnectionCard vodContent={makeEpisodeContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeEpisodeContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText(/Test Series/)).toBeInTheDocument();
expect(screen.getByText(/Season 1/)).toBeInTheDocument();
});
it('does not call getMovieDisplayTitle for an episode', () => {
render(<VodConnectionCard vodContent={makeEpisodeContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeEpisodeContent()}
stopVODClient={vi.fn()}
/>
);
expect(getMovieDisplayTitle).not.toHaveBeenCalled();
});
});
@ -302,12 +457,22 @@ describe('VodConnectionCard', () => {
describe('unknown content type', () => {
it('renders content_name as fallback title', () => {
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeUnknownContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Some Stream')).toBeInTheDocument();
});
it('does not call getMovieDisplayTitle or getEpisodeDisplayTitle', () => {
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeUnknownContent()}
stopVODClient={vi.fn()}
/>
);
expect(getMovieDisplayTitle).not.toHaveBeenCalled();
expect(getEpisodeDisplayTitle).not.toHaveBeenCalled();
});
@ -315,7 +480,12 @@ describe('VodConnectionCard', () => {
it('renders no subtitle when subtitle parts are empty', () => {
vi.mocked(getMovieSubtitle).mockReturnValue([]);
vi.mocked(getEpisodeSubtitle).mockReturnValue([]);
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeUnknownContent()}
stopVODClient={vi.fn()}
/>
);
// No " " separator rendered for empty subtitle
expect(screen.queryByText(' • ')).not.toBeInTheDocument();
});
@ -326,7 +496,9 @@ describe('VodConnectionCard', () => {
describe('connection source fallback', () => {
it('uses individual_connection when present', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
});
@ -336,7 +508,9 @@ describe('VodConnectionCard', () => {
individual_connection: null,
connections: [connection],
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
});
});
@ -345,7 +519,12 @@ describe('VodConnectionCard', () => {
describe('M3U profile', () => {
it('renders M3U profile name when present', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Main M3U')).toBeInTheDocument();
});
@ -353,7 +532,9 @@ describe('VodConnectionCard', () => {
const vodContent = makeMovieContent({
individual_connection: makeConnection({ m3u_profile: null }),
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(screen.queryByText('Main M3U')).not.toBeInTheDocument();
});
});
@ -364,14 +545,24 @@ describe('VodConnectionCard', () => {
it('calls stopVODClient with the connection when stop button is clicked', () => {
const stopVODClient = vi.fn();
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={stopVODClient} />);
render(
<VodConnectionCard
vodContent={vodContent}
stopVODClient={stopVODClient}
/>
);
fireEvent.click(screen.getByTestId('icon-square-x').closest('button'));
expect(stopVODClient).toHaveBeenCalledWith('client-abc-123');
});
it('calls stopVODClient once per click', () => {
const stopVODClient = vi.fn();
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={stopVODClient} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={stopVODClient}
/>
);
fireEvent.click(screen.getByTestId('icon-square-x').closest('button'));
fireEvent.click(screen.getByTestId('icon-square-x').closest('button'));
expect(stopVODClient).toHaveBeenCalledTimes(2);
@ -382,30 +573,58 @@ describe('VodConnectionCard', () => {
describe('client expand/collapse', () => {
it('starts collapsed (Show Details visible)', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Show Details')).toBeInTheDocument();
expect(screen.queryByText('Hide Details')).not.toBeInTheDocument();
});
it('expands to show client details on header click', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
expect(screen.getByText('Hide Details')).toBeInTheDocument();
});
it('shows user agent in expanded details', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
expect(screen.getByText('Plex/1.0')).toBeInTheDocument();
});
it('collapses back when header is clicked again', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
fireEvent.click(screen.getByText('Hide Details').closest('[data-testid="group"]'));
fireEvent.click(
screen.getByText('Hide Details').closest('[data-testid="group"]')
);
expect(screen.getByText('Show Details')).toBeInTheDocument();
});
@ -413,8 +632,12 @@ describe('VodConnectionCard', () => {
const vodContent = makeMovieContent({
individual_connection: makeConnection({ user_agent: 'Unknown' }),
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
});
@ -423,8 +646,12 @@ describe('VodConnectionCard', () => {
const vodContent = makeMovieContent({
individual_connection: makeConnection({ user_agent: null }),
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
// "Plex/1.0" should not appear
expect(screen.queryByText('Plex/1.0')).not.toBeInTheDocument();
@ -434,8 +661,12 @@ describe('VodConnectionCard', () => {
const vodContent = makeMovieContent({
individual_connection: makeConnection({ bytes_sent: 0 }),
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
expect(screen.queryByText('Data Sent:')).not.toBeInTheDocument();
});
@ -444,8 +675,12 @@ describe('VodConnectionCard', () => {
const vodContent = makeMovieContent({
individual_connection: makeConnection({ duration: 0 }),
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
const header = screen.getByText('Show Details').closest('[data-testid="group"]');
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
const header = screen
.getByText('Show Details')
.closest('[data-testid="group"]');
fireEvent.click(header);
expect(screen.queryByText('Watch Duration:')).not.toBeInTheDocument();
});
@ -455,26 +690,58 @@ describe('VodConnectionCard', () => {
describe('ConnectionProgress', () => {
it('does not render progress bar when totalTime is 0', () => {
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 0, currentTime: 0, percentage: 0 });
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
vi.mocked(calculateProgress).mockReturnValue({
totalTime: 0,
currentTime: 0,
percentage: 0,
});
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
});
it('renders progress bar when totalTime > 0', () => {
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 3600, percentage: 50 });
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
vi.mocked(calculateProgress).mockReturnValue({
totalTime: 7200,
currentTime: 3600,
percentage: 50,
});
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByTestId('progress')).toBeInTheDocument();
});
it('passes correct percentage value to Progress component', () => {
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 3600, percentage: 50 });
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByTestId('progress')).toHaveAttribute('data-value', '50');
vi.mocked(calculateProgress).mockReturnValue({
totalTime: 7200,
currentTime: 3600,
percentage: 50,
});
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByTestId('progress')).toHaveAttribute(
'data-value',
'50'
);
});
it('calls calculateProgress with connection and duration_secs', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(calculateProgress).toHaveBeenCalledWith(
vodContent.individual_connection,
vodContent.content_metadata.duration_secs
@ -484,7 +751,10 @@ describe('VodConnectionCard', () => {
it('does not render ConnectionProgress when no connection', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: null, connections: null })}
vodContent={makeMovieContent({
individual_connection: null,
connections: null,
})}
stopVODClient={vi.fn()}
/>
);
@ -496,11 +766,22 @@ describe('VodConnectionCard', () => {
describe('progress update timer', () => {
it('sets up a 1-second interval to trigger re-renders', () => {
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 0, percentage: 0 });
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
vi.mocked(calculateProgress).mockReturnValue({
totalTime: 7200,
currentTime: 0,
percentage: 0,
});
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
const callsBefore = vi.mocked(calculateProgress).mock.calls.length;
act(() => { vi.advanceTimersByTime(1000); });
act(() => {
vi.advanceTimersByTime(1000);
});
const callsAfter = vi.mocked(calculateProgress).mock.calls.length;
expect(callsAfter).toBeGreaterThan(callsBefore);
@ -509,7 +790,10 @@ describe('VodConnectionCard', () => {
it('clears the interval on unmount', () => {
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const { unmount } = render(
<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
@ -521,23 +805,34 @@ describe('VodConnectionCard', () => {
describe('connection duration and start time', () => {
it('calls calculateConnectionDuration with the connection', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(calculateConnectionDuration).toHaveBeenCalledWith(vodContent.individual_connection);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(calculateConnectionDuration).toHaveBeenCalledWith(
vodContent.individual_connection
);
});
it('renders the duration returned by calculateConnectionDuration', () => {
vi.mocked(calculateConnectionDuration).mockReturnValue('12m 45s');
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('12m 45s')).toBeInTheDocument();
});
it('calls calculateConnectionStartTime with connection and fullDateTimeFormat', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(calculateConnectionStartTime).toHaveBeenCalledWith(
vodContent.individual_connection,
'MM/DD/YYYY h:mm A'
);
});
});
});
});

View file

@ -19,11 +19,11 @@ import {
Popover,
ScrollArea,
Center,
SegmentedControl,
} from '@mantine/core';
import { Info } from 'lucide-react';
import { Info, CircleCheck, CircleX } from 'lucide-react';
import useChannelsStore from '../../store/channels';
import useStreamProfilesStore from '../../store/streamProfiles';
import { CircleCheck, CircleX } from 'lucide-react';
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
import { FixedSizeList as List } from 'react-window';
import LazyLogo from '../LazyLogo';
@ -54,6 +54,7 @@ const LiveGroupFilter = ({
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles);
const [groupFilter, setGroupFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [epgSources, setEpgSources] = useState([]);
// Logo selection functionality
@ -177,13 +178,22 @@ const LiveGroupFilter = ({
setCurrentEditingGroupId(null);
};
const isVisible = (group) => {
const matchesText = group.name
.toLowerCase()
.includes(groupFilter.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'enabled' && group.enabled) ||
(statusFilter === 'disabled' && !group.enabled);
return matchesText && matchesStatus;
};
const selectAll = () => {
setGroupStates(
groupStates.map((state) => ({
...state,
enabled: state.name.toLowerCase().includes(groupFilter.toLowerCase())
? true
: state.enabled,
enabled: isVisible(state) ? true : state.enabled,
}))
);
};
@ -192,9 +202,7 @@ const LiveGroupFilter = ({
setGroupStates(
groupStates.map((state) => ({
...state,
enabled: state.name.toLowerCase().includes(groupFilter.toLowerCase())
? false
: state.enabled,
enabled: isVisible(state) ? false : state.enabled,
}))
);
};
@ -220,7 +228,7 @@ const LiveGroupFilter = ({
description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later."
/>
<Flex gap="sm">
<Flex gap="sm" align="center">
<TextInput
placeholder="Filter groups..."
value={groupFilter}
@ -228,6 +236,16 @@ const LiveGroupFilter = ({
style={{ flex: 1 }}
size="xs"
/>
<SegmentedControl
value={statusFilter}
onChange={setStatusFilter}
size="xs"
data={[
{ label: 'All', value: 'all' },
{ label: 'Enabled', value: 'enabled' },
{ label: 'Disabled', value: 'disabled' },
]}
/>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
@ -245,9 +263,7 @@ const LiveGroupFilter = ({
verticalSpacing="xs"
>
{groupStates
.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
)
.filter((group) => isVisible(group))
.sort((a, b) => a.name.localeCompare(b.name))
.map((group) => (
<Group

View file

@ -12,7 +12,6 @@ import {
Flex,
Select,
FileInput,
useMantineTheme,
NumberInput,
Divider,
Stack,
@ -37,8 +36,6 @@ const M3U = ({
onClose,
playlistCreated = false,
}) => {
const theme = useMantineTheme();
const userAgents = useUserAgentsStore((s) => s.userAgents);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
const fetchEPGs = useEPGsStore((s) => s.fetchEPGs);
@ -398,13 +395,14 @@ const M3U = ({
<Divider size="sm" orientation="vertical" />
<Stack gap="5" style={{ flex: 1 }}>
<TextInput
<NumberInput
style={{ width: '100%' }}
id="max_streams"
name="max_streams"
label="Max Streams"
placeholder="0 = Unlimited"
description="Maximum number of concurrent streams (0 for unlimited)"
min={0}
{...form.getInputProps('max_streams')}
key={form.key('max_streams')}
/>

View file

@ -1,52 +1,20 @@
// Modal.js
import React, { useState, useEffect, forwardRef } from 'react';
import * as Yup from 'yup';
import React, { useState, useEffect } from 'react';
import API from '../../api';
import M3UProfiles from './M3UProfiles';
import {
LoadingOverlay,
TextInput,
Button,
Checkbox,
Modal,
Flex,
NativeSelect,
FileInput,
Select,
Space,
Chip,
Stack,
Group,
Center,
SimpleGrid,
Text,
NumberInput,
Divider,
Alert,
Box,
MultiSelect,
Tooltip,
Tabs,
} from '@mantine/core';
import { Info } from 'lucide-react';
import useChannelsStore from '../../store/channels';
import useVODStore from '../../store/useVODStore';
import { CircleCheck, CircleX } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import LiveGroupFilter from './LiveGroupFilter';
import VODCategoryFilter from './VODCategoryFilter';
// Custom item component for MultiSelect with tooltip
const OptionWithTooltip = forwardRef(
({ label, description, ...others }, ref) => (
<Tooltip label={description} withArrow>
<div ref={ref} {...others}>
{label}
</div>
</Tooltip>
)
);
const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
const channelGroups = useChannelsStore((s) => s.channelGroups);
const fetchCategories = useVODStore((s) => s.fetchCategories);

View file

@ -15,22 +15,22 @@ import {
Grid,
Textarea,
NumberInput,
SegmentedControl,
} from '@mantine/core';
import { DateTimePicker } from '@mantine/dates';
import { useWebSocket } from '../../WebSocket';
import usePlaylistsStore from '../../store/playlists';
const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const [websocketReady, sendMessage] = useWebSocket();
const profileSearchPreview = usePlaylistsStore((s) => s.profileSearchPreview);
const profileResult = usePlaylistsStore((s) => s.profileResult);
const [streamUrl, setStreamUrl] = useState('');
const [searchPattern, setSearchPattern] = useState('');
const [replacePattern, setReplacePattern] = useState('');
const [debouncedPatterns, setDebouncedPatterns] = useState({});
const [sampleInput, setSampleInput] = useState('');
const [xcMode, setXcMode] = useState('simple');
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [simpleErrors, setSimpleErrors] = useState({});
const isDefaultProfile = profile?.is_default;
const isXC = m3u?.account_type === 'XC';
@ -50,12 +50,12 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
const schema = Yup.object({
name: Yup.string().required('Name is required'),
search_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Search pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
replace_pattern: Yup.string().when([], {
is: () => !isDefaultProfile,
is: () => !isDefaultProfile && !isXC,
then: (schema) => schema.required('Replace pattern is required'),
otherwise: (schema) => schema.notRequired(),
}),
@ -69,6 +69,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
reset,
setValue,
watch,
setError,
} = useForm({
defaultValues,
resolver: yupResolver(schema),
@ -88,6 +89,32 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
expDateValue = null;
}
// For XC simple mode: validate simple inputs and build patterns from credentials
if (isXC && xcMode === 'simple' && !isDefaultProfile) {
const errs = {};
if (!newUsername.trim()) errs.newUsername = 'New username is required';
if (!newPassword.trim()) errs.newPassword = 'New password is required';
if (Object.keys(errs).length > 0) {
setSimpleErrors(errs);
return;
}
setSimpleErrors({});
values.search_pattern = `${m3u?.username || ''}/${m3u?.password || ''}`;
values.replace_pattern = `${newUsername.trim()}/${newPassword.trim()}`;
}
// For XC advanced mode: validate regex pattern fields
if (isXC && xcMode === 'advanced' && !isDefaultProfile) {
if (!searchPattern.trim()) {
setError('search_pattern', { message: 'Search pattern is required' });
return;
}
if (!replacePattern.trim()) {
setError('replace_pattern', { message: 'Replace pattern is required' });
return;
}
}
// For default profiles, only send name and custom_properties (notes)
let submitValues;
if (isDefaultProfile) {
@ -110,6 +137,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
// Preserve existing custom_properties and add/update notes
...(profile?.custom_properties || {}),
notes: values.notes || '',
...(isXC ? { xcMode } : {}),
},
};
}
@ -172,7 +200,14 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
} catch (error) {
console.error('Error sending WebSocket message:', error);
}
}, [websocketReady, m3u, debouncedPatterns, streamUrl, sampleInput]);
}, [
websocketReady,
sendMessage,
m3u,
debouncedPatterns,
streamUrl,
sampleInput,
]);
useEffect(() => {
const handler = setTimeout(() => {
@ -198,13 +233,64 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
reset(defaultValues);
setSearchPattern(profile?.search_pattern || '');
setReplacePattern(profile?.replace_pattern || '');
}, [defaultValues, profile, reset]);
if (isXC && !isDefaultProfile) {
const storedMode = profile?.custom_properties?.xcMode;
let detectedMode;
if (storedMode) {
detectedMode = storedMode;
} else if (
profile?.search_pattern &&
profile.search_pattern === `${m3u?.username}/${m3u?.password}`
) {
detectedMode = 'simple';
} else if (profile?.search_pattern) {
detectedMode = 'advanced';
} else {
detectedMode = 'simple';
}
setXcMode(detectedMode);
if (detectedMode === 'simple') {
const rp = profile?.replace_pattern || '';
const idx = rp.indexOf('/');
setNewUsername(idx === -1 ? rp : rp.slice(0, idx));
setNewPassword(idx === -1 ? '' : rp.slice(idx + 1));
}
}
}, [
defaultValues,
isDefaultProfile,
isXC,
m3u?.password,
m3u?.username,
profile,
reset,
]);
const handleSampleInputChange = (e) => {
setSampleInput(e.target.value);
};
// Local regex testing for immediate visual feedback
const handleXcModeChange = (mode) => {
if (mode === 'advanced' && xcMode === 'simple') {
// Pre-populate regex fields from current simple values
const sp = `${m3u?.username || ''}/${m3u?.password || ''}`;
const rp = `${newUsername}/${newPassword}`;
setSearchPattern(sp);
setReplacePattern(rp);
setValue('search_pattern', sp);
setValue('replace_pattern', rp);
} else if (mode === 'simple' && xcMode === 'advanced') {
// Parse current replace pattern back into username/password
const idx = replacePattern.indexOf('/');
setNewUsername(
idx === -1 ? replacePattern : replacePattern.slice(0, idx)
);
setNewPassword(idx === -1 ? '' : replacePattern.slice(idx + 1));
}
setXcMode(mode);
};
// Local regex for the live demo preview
const getHighlightedSearchText = () => {
if (!searchPattern || !sampleInput) return sampleInput;
try {
@ -242,6 +328,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
<form onSubmit={handleSubmit(onSubmit)}>
<TextInput
label="Name"
description="A label to identify this URL rewrite profile"
placeholder="e.g. Provider A - 2nd Connection"
{...register('name')}
error={errors.name?.message}
/>
@ -250,6 +338,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
{!isDefaultProfile && (
<NumberInput
label="Max Streams"
description="Maximum concurrent streams allowed for this profile. Set to 0 for unlimited."
{...register('max_streams')}
value={watch('max_streams')}
onChange={(value) => setValue('max_streams', value || 0)}
@ -262,18 +351,65 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
{/* Only show search/replace fields for non-default profiles */}
{!isDefaultProfile && (
<>
<TextInput
label="Search Pattern (Regex)"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={errors.search_pattern?.message}
/>
<TextInput
label="Replace Pattern"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={errors.replace_pattern?.message}
/>
{isXC && (
<SegmentedControl
mt="xs"
mb="xs"
fullWidth
size="xs"
value={xcMode}
onChange={handleXcModeChange}
data={[
{ label: 'Simple', value: 'simple' },
{ label: 'Advanced (Regex)', value: 'advanced' },
]}
/>
)}
{isXC && xcMode === 'simple' ? (
<>
<TextInput
label="New Username"
description="Your updated XC account username. The current username in all stream URLs will be replaced with this."
placeholder="e.g. username2"
value={newUsername}
onChange={(e) => {
setNewUsername(e.target.value);
setSimpleErrors((s) => ({ ...s, newUsername: undefined }));
}}
error={simpleErrors.newUsername}
/>
<TextInput
label="New Password"
description="Your updated XC account password. The current password in all stream URLs will be replaced with this."
placeholder="e.g. password2"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setSimpleErrors((s) => ({ ...s, newPassword: undefined }));
}}
error={simpleErrors.newPassword}
/>
</>
) : (
<>
<TextInput
label="Search Pattern (Regex)"
description="A regular expression matching the part of the stream URL you want to replace. For most users, matching just the credentials is enough."
placeholder="e.g. username1/password1"
value={searchPattern}
onChange={onSearchPatternUpdate}
error={errors.search_pattern?.message}
/>
<TextInput
label="Replace Pattern"
description="The value to substitute in place of the matched text. Use $1, $2, etc. to reference regex capture groups."
placeholder="e.g. username2/password2"
value={replacePattern}
onChange={onReplacePatternUpdate}
error={errors.replace_pattern?.message}
/>
</>
)}
</>
)}
@ -317,8 +453,8 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
</Flex>
</form>
{/* Only show regex demonstration for non-default profiles */}
{!isDefaultProfile && (
{/* Only show regex demonstration for non-default profiles in advanced mode */}
{!isDefaultProfile && (!isXC || xcMode === 'advanced') && (
<>
<Title order={4} mt={15} mb={10}>
Live Regex Demonstration
@ -339,7 +475,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => {
<Grid gutter="xs">
<Grid.Col span={12}>
<Paper shadow="sm" p="xs" radius="md" withBorder>
<Text size="sm" weight={500} mb={3}>
<Text size="sm" weight={500} mb={3} component="div">
Matched Text{' '}
<Badge size="xs" color="yellow">
highlighted

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Modal, Flex, Button } from '@mantine/core';
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../pages/guideUtils.js';
import { deleteSeriesRuleByTvgId } from '../../utils/guideUtils.js';
export default function ProgramRecordingModal({
opened,

View file

@ -5,7 +5,7 @@ import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
import {
evaluateSeriesRulesByTvgId,
fetchRules,
} from '../../pages/guideUtils.js';
} from '../../utils/guideUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
export default function SeriesRecordingModal({

View file

@ -17,6 +17,7 @@ import {
Tooltip,
Grid,
SimpleGrid,
NumberInput,
useMantineTheme,
} from '@mantine/core';
import { RotateCcwKey, RotateCw, X } from 'lucide-react';
@ -48,6 +49,7 @@ const User = ({ user = null, isOpen, onClose }) => {
last_name: '',
email: '',
user_level: '0',
stream_limit: 0,
password: '',
xc_password: '',
channel_profiles: [],
@ -117,7 +119,11 @@ const User = ({ user = null, isOpen, onClose }) => {
delete values.password;
}
const response = await API.updateUser(user.id, values);
const response = await API.updateUser(
user.id,
values,
isAdmin ? false : authUser.id === user.id
);
if (user.id == authUser.id) {
setUser(response);
@ -139,6 +145,7 @@ const User = ({ user = null, isOpen, onClose }) => {
last_name: user.last_name || '',
email: user.email,
user_level: `${user.user_level}`,
stream_limit: user.stream_limit || 0,
channel_profiles:
user.channel_profiles.length > 0
? user.channel_profiles.map((id) => `${id}`)
@ -236,6 +243,7 @@ const User = ({ user = null, isOpen, onClose }) => {
id="username"
name="username"
label="Username"
disabled={!isAdmin}
{...form.getInputProps('username')}
key={form.key('username')}
/>
@ -257,19 +265,26 @@ const User = ({ user = null, isOpen, onClose }) => {
/>
{showPermissions && (
<Select
label="User Level"
data={Object.entries(USER_LEVELS).map(([, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
{...form.getInputProps('user_level')}
key={form.key('user_level')}
/>
)}
<>
<Select
label="User Level"
data={Object.entries(USER_LEVELS).map(([, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
{...form.getInputProps('user_level')}
key={form.key('user_level')}
/>
<NumberInput
label="Stream Limit (0 = unlimited)"
{...form.getInputProps('stream_limit')}
key={form.key('stream_limit')}
/>
</>
)}
</Stack>
<Stack gap="xs" style={{ flex: 1 }}>
@ -289,26 +304,23 @@ const User = ({ user = null, isOpen, onClose }) => {
key={form.key('last_name')}
/>
<Group align="flex-end">
<TextInput
label="XC Password"
description="Clear to disable XC API"
{...form.getInputProps('xc_password')}
key={form.key('xc_password')}
style={{ flex: 1 }}
rightSectionWidth={30}
rightSection={
<ActionIcon
variant="transparent"
size="sm"
color="white"
onClick={generateXCPassword}
>
<RotateCcwKey />
</ActionIcon>
}
/>
</Group>
<TextInput
label="XC Password"
description="Clear to disable XC API"
{...form.getInputProps('xc_password')}
key={form.key('xc_password')}
rightSectionWidth={30}
rightSection={
<ActionIcon
variant="transparent"
size="sm"
color="white"
onClick={generateXCPassword}
>
<RotateCcwKey />
</ActionIcon>
}
/>
{showPermissions && (
<MultiSelect

View file

@ -8,9 +8,9 @@ import {
Group,
SimpleGrid,
Text,
Divider,
Box,
Checkbox,
SegmentedControl,
} from '@mantine/core';
import { CircleCheck, CircleX } from 'lucide-react';
import useVODStore from '../../store/useVODStore';
@ -25,6 +25,7 @@ const VODCategoryFilter = ({
}) => {
const categories = useVODStore((s) => s.categories);
const [filter, setFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
useEffect(() => {
if (Object.keys(categories).length === 0) {
@ -64,13 +65,22 @@ const VODCategoryFilter = ({
);
};
const isVisible = (category) => {
const matchesText = category.name
.toLowerCase()
.includes(filter.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'enabled' && category.enabled) ||
(statusFilter === 'disabled' && !category.enabled);
return matchesText && matchesStatus;
};
const selectAll = () => {
setCategoryStates(
categoryStates.map((state) => ({
...state,
enabled: state.name.toLowerCase().includes(filter.toLowerCase())
? true
: state.enabled,
enabled: isVisible(state) ? true : state.enabled,
}))
);
};
@ -79,9 +89,7 @@ const VODCategoryFilter = ({
setCategoryStates(
categoryStates.map((state) => ({
...state,
enabled: state.name.toLowerCase().includes(filter.toLowerCase())
? false
: state.enabled,
enabled: isVisible(state) ? false : state.enabled,
}))
);
};
@ -98,7 +106,7 @@ const VODCategoryFilter = ({
description="When disabled, new categories from the provider will be created but disabled by default. You can enable them manually later."
/>
<Flex gap="sm">
<Flex gap="sm" align="center">
<TextInput
placeholder="Filter categories..."
value={filter}
@ -106,6 +114,16 @@ const VODCategoryFilter = ({
style={{ flex: 1 }}
size="xs"
/>
<SegmentedControl
value={statusFilter}
onChange={setStatusFilter}
size="xs"
data={[
{ label: 'All', value: 'all' },
{ label: 'Enabled', value: 'enabled' },
{ label: 'Disabled', value: 'disabled' },
]}
/>
<Button variant="default" size="xs" onClick={selectAll}>
Select Visible
</Button>
@ -121,9 +139,7 @@ const VODCategoryFilter = ({
verticalSpacing="xs"
>
{categoryStates
.filter((category) => {
return category.name.toLowerCase().includes(filter.toLowerCase());
})
.filter((category) => isVisible(category))
.sort((a, b) => a.name.localeCompare(b.name))
.map((category) => (
<Group

View file

@ -0,0 +1,170 @@
import React from 'react';
import {
Badge,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Tooltip,
} from '@mantine/core';
import useSettingsStore from '../../../store/settings.jsx';
const TlsOption = ({ label, description, tooltip, badgeText, badgeColor }) => (
<div>
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
{label}
</Text>
<Tooltip label={tooltip}>
<Badge color={badgeColor} variant="light" size="sm">
{badgeText}
</Badge>
</Tooltip>
</Group>
<Text size="xs" c="dimmed">
{description}
</Text>
</div>
);
const TlsServiceCard = ({ serviceName, children }) => (
<Paper p="md" withBorder h="100%">
<Stack gap="sm">
<Text fw={600}>{serviceName}</Text>
{children}
</Stack>
</Paper>
);
const RedisStatus = ({ tls }) => {
const enabled = tls?.enabled ?? false;
const verify = tls?.verify ?? true;
const mtls = tls?.mtls ?? false;
let verifyColor = 'gray';
let verifyTooltip = 'Verification is not active — TLS is disabled';
if (enabled) {
verifyColor = verify ? 'green' : 'yellow';
verifyTooltip = verify
? "The server's identity is verified using a trusted certificate"
: 'The connection is encrypted but the server identity is not verified';
}
let mtlsColor = 'gray';
let mtlsTooltip = 'Mutual authentication is not active';
if (enabled && mtls) {
mtlsColor = 'green';
mtlsTooltip = 'Both client and server verify each other with certificates';
}
return (
<TlsServiceCard serviceName="Redis">
<TlsOption
label="Encryption"
description="Encrypt traffic between Dispatcharr and Redis."
tooltip={
enabled
? 'The connection is encrypted'
: 'The connection is not encrypted'
}
badgeText={enabled ? 'Enabled' : 'Disabled'}
badgeColor={enabled ? 'green' : 'gray'}
/>
<TlsOption
label="Server Verification"
description="Verify the Redis server's identity using a CA certificate."
tooltip={verifyTooltip}
badgeText={verify && enabled ? 'On' : 'Off'}
badgeColor={verifyColor}
/>
<TlsOption
label="Mutual TLS"
description="Authenticate Dispatcharr to Redis using a client certificate."
tooltip={mtlsTooltip}
badgeText={mtls && enabled ? 'Active' : 'Inactive'}
badgeColor={mtlsColor}
/>
</TlsServiceCard>
);
};
const PostgresStatus = ({ tls }) => {
const enabled = tls?.enabled ?? false;
const sslMode = tls?.ssl_mode;
const mtls = tls?.mtls ?? false;
let modeColor = 'gray';
let modeTooltip = 'Verification mode is not active — TLS is disabled';
let modeBadge = 'Off';
if (enabled && sslMode) {
modeBadge = sslMode;
if (sslMode === 'verify-full') {
modeColor = 'green';
modeTooltip = 'Server certificate and hostname are both verified';
} else if (sslMode === 'verify-ca') {
modeColor = 'yellow';
modeTooltip =
'Server certificate is verified, but hostname is not checked';
} else {
modeColor = 'yellow';
modeTooltip = 'Connection is encrypted but the server is not verified';
}
}
let mtlsColor = 'gray';
let mtlsTooltip = 'Mutual authentication is not active';
if (enabled && mtls) {
mtlsColor = 'green';
mtlsTooltip = 'Both client and server verify each other with certificates';
}
return (
<TlsServiceCard serviceName="PostgreSQL">
<TlsOption
label="Encryption"
description="Encrypt traffic between Dispatcharr and PostgreSQL."
tooltip={
enabled
? 'The connection is encrypted'
: 'The connection is not encrypted'
}
badgeText={enabled ? 'Enabled' : 'Disabled'}
badgeColor={enabled ? 'green' : 'gray'}
/>
<TlsOption
label="Verification Mode"
description="How strictly to verify the PostgreSQL server's identity."
tooltip={modeTooltip}
badgeText={modeBadge}
badgeColor={modeColor}
/>
<TlsOption
label="Mutual TLS"
description="Authenticate Dispatcharr to PostgreSQL using a client certificate."
tooltip={mtlsTooltip}
badgeText={mtls && enabled ? 'Active' : 'Inactive'}
badgeColor={mtlsColor}
/>
</TlsServiceCard>
);
};
const ConnectionSecurityPanel = React.memo(() => {
const environment = useSettingsStore((s) => s.environment);
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
Encrypt connections to Redis and PostgreSQL using environment variables
in the docker compose file.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RedisStatus tls={environment.redis_tls} />
<PostgresStatus tls={environment.postgres_tls} />
</SimpleGrid>
</Stack>
);
});
export default ConnectionSecurityPanel;

View file

@ -1,6 +1,6 @@
import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useForm } from '@mantine/form';
import {
checkSetting,
@ -19,12 +19,14 @@ const NetworkAccessForm = React.memo(({ active }) => {
const [networkAccessError, setNetworkAccessError] = useState(null);
const [saved, setSaved] = useState(false);
const [restoredDefaults, setRestoredDefaults] = useState([]);
const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] =
useState(false);
const [saving, setSaving] = useState(false);
const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] =
useState([]);
const [clientIpAddress, setClientIpAddress] = useState(null);
const pendingSaveValuesRef = useRef(null);
const networkAccessForm = useForm({
mode: 'controlled',
@ -33,7 +35,10 @@ const NetworkAccessForm = React.memo(({ active }) => {
});
useEffect(() => {
if (!active) setSaved(false);
if (!active) {
setSaved(false);
setRestoredDefaults([]);
}
}, [active]);
useEffect(() => {
@ -58,9 +63,31 @@ const NetworkAccessForm = React.memo(({ active }) => {
const onNetworkAccessSubmit = async () => {
setSaved(false);
setNetworkAccessError(null);
setRestoredDefaults([]);
// Check for blank fields and substitute defaults before saving
const currentValues = networkAccessForm.getValues();
const defaults = getNetworkAccessDefaults();
const restoredLabels = [];
const submitValues = { ...currentValues };
Object.keys(currentValues).forEach((key) => {
if (!currentValues[key] || currentValues[key].trim() === '') {
submitValues[key] = defaults[key];
restoredLabels.push(NETWORK_ACCESS_OPTIONS[key]?.label || key);
}
});
if (restoredLabels.length > 0) {
networkAccessForm.setValues(submitValues);
setRestoredDefaults(restoredLabels);
}
pendingSaveValuesRef.current = submitValues;
const check = await checkSetting({
...settings['network_access'],
value: networkAccessForm.getValues(), // Send as object
value: submitValues,
});
if (check.error && check.message) {
@ -84,10 +111,12 @@ const NetworkAccessForm = React.memo(({ active }) => {
const saveNetworkAccess = async () => {
setSaved(false);
setSaving(true);
const values =
pendingSaveValuesRef.current || networkAccessForm.getValues();
try {
await updateSetting({
...settings['network_access'],
value: networkAccessForm.getValues(), // Send as object
value: values,
});
setSaved(true);
} catch (e) {
@ -113,6 +142,12 @@ const NetworkAccessForm = React.memo(({ active }) => {
title="Saved Successfully"
></Alert>
)}
{restoredDefaults.length > 0 && (
<Alert variant="light" color="yellow" title="Defaults Restored">
The following fields were empty and have been restored to their
defaults: {restoredDefaults.join(', ')}
</Alert>
)}
{networkAccessError && (
<Alert
variant="light"

View file

@ -5,12 +5,23 @@ import {
parseSettings,
saveChangedSettings,
} from '../../../utils/pages/SettingsUtils.js';
import { Alert, Button, Flex, NumberInput, Stack, Text } from '@mantine/core';
import {
Alert,
Button,
Divider,
Flex,
NumberInput,
Stack,
Text,
} from '@mantine/core';
import ConnectionSecurityPanel from './ConnectionSecurityPanel.jsx';
import { useForm } from '@mantine/form';
import { getSystemSettingsFormInitialValues } from '../../../utils/forms/settings/SystemSettingsFormUtils.js';
const SystemSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const isModular =
useSettingsStore((s) => s.environment.env_mode) === 'modular';
const [saved, setSaved] = useState(false);
@ -68,6 +79,12 @@ const SystemSettingsForm = React.memo(({ active }) => {
max={1000}
step={10}
/>
{isModular && (
<>
<Divider my="md" label="Connection Security" labelPosition="left" />
<ConnectionSecurityPanel />
</>
)}
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
onClick={form.onSubmit(onSubmit)}

View file

@ -0,0 +1,116 @@
import useSettingsStore from '../../../store/settings.jsx';
import React, { useEffect, useState } from 'react';
import { useForm } from '@mantine/form';
import { updateSetting } from '../../../utils/pages/SettingsUtils.js';
import {
Alert,
Button,
Flex,
NumberInput,
Stack,
TextInput,
Checkbox,
} from '@mantine/core';
import { USER_LIMITS_OPTIONS } from '../../../constants.js';
const USER_LIMIT_DEFAULTS = Object.keys(USER_LIMITS_OPTIONS).reduce(
(acc, key) => {
acc[key] = USER_LIMITS_OPTIONS[key].default;
return acc;
},
{}
);
const UserLimitsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const [saved, setSaved] = useState(false);
const userLimitSettingsForm = useForm({
mode: 'controlled',
initialValues: USER_LIMIT_DEFAULTS,
});
useEffect(() => {
if (!active) setSaved(false);
}, [active]);
useEffect(() => {
if (settings) {
if (settings['user_limit_settings']?.value) {
userLimitSettingsForm.setValues({
...USER_LIMIT_DEFAULTS,
...settings['user_limit_settings'].value,
});
}
}
}, [settings]);
const resetUserLimitsToDefaults = () => {
userLimitSettingsForm.setValues(USER_LIMIT_DEFAULTS);
};
const onUserLimitsSubmit = async () => {
setSaved(false);
try {
const result = await updateSetting({
...settings['user_limit_settings'],
value: userLimitSettingsForm.getValues(),
});
if (result) {
setSaved(true);
}
} catch (error) {
console.error('Error saving user limit settings:', error);
}
};
return (
<form onSubmit={userLimitSettingsForm.onSubmit(onUserLimitsSubmit)}>
<Stack gap="sm">
{saved && (
<Alert
variant="light"
color="green"
title="Saved Successfully"
></Alert>
)}
{Object.keys(USER_LIMITS_OPTIONS).reduce((acc, key) => {
const option = USER_LIMITS_OPTIONS[key];
acc.push(
<Checkbox
key={key}
label={option.label}
description={option.description}
{...userLimitSettingsForm.getInputProps(key, {
type: 'checkbox',
})}
/>
);
return acc;
}, [])}
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
<Button
variant="subtle"
color="gray"
onClick={resetUserLimitsToDefaults}
>
Reset to Defaults
</Button>
<Button
type="submit"
disabled={userLimitSettingsForm.submitting}
variant="default"
>
Save
</Button>
</Flex>
</Stack>
</form>
);
});
export default UserLimitsForm;

View file

@ -313,10 +313,10 @@ describe('NetworkAccessForm', () => {
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => {
expect(screen.getByTestId('alert')).toBeInTheDocument();
expect(screen.getByTestId('alert-title')).toHaveTextContent(
'Saved Successfully'
);
const alertTitles = screen.getAllByTestId('alert-title');
expect(
alertTitles.some((el) => el.textContent === 'Saved Successfully')
).toBe(true);
});
});
@ -399,9 +399,10 @@ describe('NetworkAccessForm', () => {
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => {
expect(screen.getByTestId('alert-title')).toHaveTextContent(
'Saved Successfully'
);
const alertTitles = screen.getAllByTestId('alert-title');
expect(
alertTitles.some((el) => el.textContent === 'Saved Successfully')
).toBe(true);
});
});
});

View file

@ -16,6 +16,12 @@ vi.mock('../../../../utils/forms/settings/SystemSettingsFormUtils.js', () => ({
getSystemSettingsFormInitialValues: vi.fn(),
}));
vi.mock('../ConnectionSecurityPanel.jsx', () => ({
default: () => (
<div data-testid="connection-security-panel">ConnectionSecurityPanel</div>
),
}));
// Mantine form
vi.mock('@mantine/form', () => ({
useForm: vi.fn(),
@ -47,6 +53,7 @@ vi.mock('@mantine/core', () => ({
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
Divider: () => <hr />,
}));
//
@ -69,7 +76,15 @@ const makeSettings = (overrides = {}) => ({
...overrides,
});
const setupMocks = ({ settings = makeSettings() } = {}) => {
const makeEnvironment = (overrides = {}) => ({
env_mode: 'aio',
...overrides,
});
const setupMocks = ({
settings = makeSettings(),
environment = makeEnvironment(),
} = {}) => {
const formValues = { max_system_events: settings?.max_system_events ?? 100 };
const formMock = {
@ -85,7 +100,9 @@ const setupMocks = ({ settings = makeSettings() } = {}) => {
vi.mocked(useForm).mockReturnValue(formMock);
vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues);
vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ settings }));
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings, environment })
);
vi.mocked(parseSettings).mockReturnValue(formValues);
vi.mocked(getChangedSettings).mockReturnValue({
max_system_events: settings?.max_system_events ?? 100,
@ -148,6 +165,22 @@ describe('SystemSettingsForm', () => {
expect(screen.queryByTestId('alert')).not.toBeInTheDocument();
});
it('does not render Connection Security panel in non-modular mode', () => {
setupMocks({ environment: makeEnvironment({ env_mode: 'aio' }) });
render(<SystemSettingsForm active={true} />);
expect(
screen.queryByTestId('connection-security-panel')
).not.toBeInTheDocument();
});
it('renders Connection Security panel in modular mode', () => {
setupMocks({ environment: makeEnvironment({ env_mode: 'modular' }) });
render(<SystemSettingsForm active={true} />);
expect(
screen.getByTestId('connection-security-panel')
).toBeInTheDocument();
});
it('renders NumberInput with value from form values', () => {
setupMocks({ settings: makeSettings({ max_system_events: 250 }) });
render(<SystemSettingsForm active={true} />);
@ -167,7 +200,10 @@ describe('SystemSettingsForm', () => {
vi.mocked(useForm).mockReturnValue(formMock);
vi.mocked(getSystemSettingsFormInitialValues).mockReturnValue(formValues);
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: makeSettings({ max_system_events: 0 }) })
sel({
settings: makeSettings({ max_system_events: 0 }),
environment: makeEnvironment(),
})
);
vi.mocked(parseSettings).mockReturnValue(formValues);
vi.mocked(getChangedSettings).mockReturnValue({});
@ -211,7 +247,7 @@ describe('SystemSettingsForm', () => {
max_system_events: 100,
});
vi.mocked(useSettingsStore).mockImplementation((sel) =>
sel({ settings: null })
sel({ settings: null, environment: makeEnvironment() })
);
vi.mocked(parseSettings).mockReturnValue({});
vi.mocked(saveChangedSettings).mockResolvedValue(undefined);

View file

@ -6,17 +6,22 @@ import CustomTableBody from './CustomTableBody';
const CustomTable = ({ table }) => {
const tableSize = table?.tableSize ?? 'default';
// Get column sizing state for dependency tracking
// columnSizing is read here so the memo below re-runs when columns are resized.
const columnSizing = table.getState().columnSizing;
// Calculate minimum table width reactively based on column sizes
// Calculate minimum table width reactively based on column sizes.
// Grow columns contribute only their minSize (not TanStack's default 150px)
// so the wrapper doesn't force the table wider than its container.
const minTableWidth = useMemo(() => {
void columnSizing; // reactive trigger: recalculate when column sizes change
const headerGroups = table.getHeaderGroups();
if (!headerGroups || headerGroups.length === 0) return 0;
const width =
headerGroups[0]?.headers.reduce((total, header) => {
return total + header.getSize();
const colDef = header.column.columnDef;
const size = colDef.grow ? colDef.minSize || 0 : header.getSize();
return total + size;
}, 0) || 0;
return width;

View file

@ -126,9 +126,6 @@ const CustomTableBody = ({
}}
>
{row.getVisibleCells().map((cell) => {
const hasFixedSize = cell.column.columnDef.size;
const isFlexible = !hasFixedSize;
return (
<Box
className="td"
@ -136,8 +133,11 @@ const CustomTableBody = ({
style={{
...(cell.column.columnDef.grow
? {
flex: '1 1 0%',
flex: `${cell.column.columnDef.grow === true ? 1 : cell.column.columnDef.grow} 1 0%`,
minWidth: 0,
...(cell.column.columnDef.maxSize && {
maxWidth: `${cell.column.columnDef.maxSize}px`,
}),
}
: {
flex: `0 0 ${cell.column.getSize ? cell.column.getSize() : 150}px`,

View file

@ -110,8 +110,11 @@ const CustomTableHeader = ({
style={{
...(header.column.columnDef.grow
? {
flex: '1 1 0%',
flex: `${header.column.columnDef.grow === true ? 1 : header.column.columnDef.grow} 1 0%`,
minWidth: 0,
...(header.column.columnDef.maxSize && {
maxWidth: `${header.column.columnDef.maxSize}px`,
}),
}
: {
flex: `0 0 ${header.getSize ? header.getSize() : 150}px`,

View file

@ -585,9 +585,57 @@ const M3UTable = () => {
},
{
header: 'Max Streams',
accessorKey: 'max_streams',
id: 'max_streams',
accessorFn: (row) => {
const activeProfiles = (row.profiles || []).filter(
(p) => p.is_active
);
if (activeProfiles.length === 0) return row.max_streams;
if (activeProfiles.some((p) => p.max_streams === 0)) return Infinity;
return activeProfiles.reduce((sum, p) => sum + p.max_streams, 0);
},
sortable: true,
size: 125,
cell: ({ row }) => {
const profiles = row.original.profiles || [];
const activeProfiles = profiles.filter((p) => p.is_active);
if (activeProfiles.length <= 1) {
const val = row.original.max_streams;
return <Text size="xs">{val === 0 ? '∞' : val}</Text>;
}
const hasUnlimited = activeProfiles.some((p) => p.max_streams === 0);
const total = hasUnlimited
? null
: activeProfiles.reduce((sum, p) => sum + p.max_streams, 0);
const tooltipLines = activeProfiles
.map(
(p) =>
`${p.name}: ${p.max_streams === 0 ? 'Unlimited' : p.max_streams}`
)
.join('\n');
return (
<Tooltip
label={tooltipLines}
multiline
width={220}
style={{ whiteSpace: 'pre-line' }}
>
<Text
size="xs"
style={{
cursor: 'default',
textDecoration: 'underline dotted',
}}
>
{hasUnlimited ? '∞' : total}
</Text>
</Tooltip>
);
},
},
{
header: 'Expiration',

View file

@ -62,6 +62,33 @@ export const PROXY_SETTINGS_OPTIONS = {
},
};
export const USER_LIMITS_OPTIONS = {
terminate_on_limit_exceeded: {
label: 'Terminate on Limit Exceeded',
description:
'Terminate a stream (based on below criteria) when the user exceeds the allowed limits',
default: true,
},
prioritize_single_client_channels: {
label: 'Prioritize Single Client Channels',
description:
'Prioritize terminating channels only a single client belonging to the user',
default: true,
},
ignore_same_channel_connections: {
label: 'Ignore Same-Channel Connections',
description:
'Multiple user connections to the same channel count as 1 connection toward user limits',
default: false,
},
terminate_oldest: {
label: 'Terminate Oldest',
description:
'Prioritize terminating the oldest stream when limits are exceeded. Setting to false prioritizes the newest stream.',
default: true,
},
};
export const M3U_FILTER_TYPES = [
{
label: 'Group',

View file

@ -66,7 +66,7 @@ import {
PX_PER_MS,
calcProgressPct,
sortChannels,
} from './guideUtils';
} from '../utils/guideUtils';
import API from '../api';
import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js';
import {
@ -387,7 +387,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
// Pixel offset for the "now" vertical line
@ -863,7 +864,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
(startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const widthPx =
(durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH - PROGRAM_GAP_PX * 2;
(durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH -
PROGRAM_GAP_PX * 2;
const recording = recordingsByProgramId.get(program.id);
@ -960,14 +962,25 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Text>
{/* Row 2: S/E badge + Subtitle or description fallback */}
{(seasonEpisodeLabel || program.sub_title || program.description) && (
<Group gap={4} wrap="nowrap" style={{ overflow: 'hidden', minWidth: 0 }}>
{(seasonEpisodeLabel ||
program.sub_title ||
program.description) && (
<Group
gap={4}
wrap="nowrap"
style={{ overflow: 'hidden', minWidth: 0 }}
>
{seasonEpisodeLabel && (
<Badge
size="xs"
variant="light"
color="cyan"
styles={{ root: { backgroundColor: 'rgba(20,90,110,0.55)', flexShrink: 0 } }}
styles={{
root: {
backgroundColor: 'rgba(20,90,110,0.55)',
flexShrink: 0,
},
}}
>
{seasonEpisodeLabel}
</Badge>
@ -1007,7 +1020,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
size="xs"
variant="light"
color="red"
styles={{ root: { backgroundColor: 'rgba(120,20,20,0.55)', flexShrink: 0 } }}
styles={{
root: {
backgroundColor: 'rgba(120,20,20,0.55)',
flexShrink: 0,
},
}}
>
LIVE
</Badge>
@ -1017,7 +1035,12 @@ export default function TVChannelGuide({ startDate, endDate }) {
size="xs"
variant="light"
color="green"
styles={{ root: { backgroundColor: 'rgba(20,100,20,0.55)', flexShrink: 0 } }}
styles={{
root: {
backgroundColor: 'rgba(20,100,20,0.55)',
flexShrink: 0,
},
}}
>
NEW
</Badge>
@ -1026,39 +1049,44 @@ export default function TVChannelGuide({ startDate, endDate }) {
</Box>
{/* Progress bar for currently-airing programs — updated every second via DOM */}
{isLive && (() => {
const durationMs = programEndMs - programStartMs;
if (durationMs <= 0) return null;
const initialPct = calcProgressPct(Date.now(), programStartMs, durationMs);
return (
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
h={4}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: '0 0 8px 8px',
overflow: 'hidden',
}}
>
{isLive &&
(() => {
const durationMs = programEndMs - programStartMs;
if (durationMs <= 0) return null;
const initialPct = calcProgressPct(
Date.now(),
programStartMs,
durationMs
);
return (
<Box
className="guide-progress-fill"
data-start-ms={programStartMs}
data-end-ms={programEndMs}
h="100%"
pos="absolute"
bottom={0}
left={0}
right={0}
h={4}
style={{
width: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderRadius: '0 0 0 8px',
transformOrigin: 'left',
transform: `scaleX(${initialPct})`,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderRadius: '0 0 8px 8px',
overflow: 'hidden',
}}
/>
</Box>
);
})()}
>
<Box
className="guide-progress-fill"
data-start-ms={programStartMs}
data-end-ms={programEndMs}
h="100%"
style={{
width: '100%',
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderRadius: '0 0 0 8px',
transformOrigin: 'left',
transform: `scaleX(${initialPct})`,
}}
/>
</Box>
);
})()}
</Paper>
</Box>
);
@ -1354,7 +1382,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
/>
</Box>
</Box>
</Box>
</Box>

View file

@ -24,6 +24,9 @@ import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
import ErrorBoundary from '../components/ErrorBoundary.jsx';
const UserLimitsForm = React.lazy(
() => import('../components/forms/settings/UserLimitsForm.jsx')
);
const NetworkAccessForm = React.lazy(
() => import('../components/forms/settings/NetworkAccessForm.jsx')
);
@ -200,6 +203,19 @@ const SettingsPage = () => {
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="user-limits">
<AccordionControl>User Limits</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<UserLimitsForm
active={accordianValue === 'user-limits'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
</>
)}
</Accordion>

View file

@ -10,7 +10,7 @@ import useSettingsStore from '../../store/settings';
import useVideoStore from '../../store/useVideoStore';
import useLocalStorage from '../../hooks/useLocalStorage';
import { showNotification } from '../../utils/notificationUtils.js';
import * as guideUtils from '../guideUtils';
import * as guideUtils from '../../utils/guideUtils';
import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js';
import * as dateTimeUtils from '../../utils/dateTimeUtils.js';
import userEvent from '@testing-library/user-event';
@ -127,7 +127,12 @@ vi.mock('@mantine/core', async () => {
</select>
),
Badge: ({ children, size, variant, color, style }) => (
<span data-size={size} data-variant={variant} data-color={color} style={style}>
<span
data-size={size}
data-variant={variant}
data-color={color}
style={style}
>
{children}
</span>
),
@ -208,8 +213,8 @@ vi.mock('../../components/ProgramDetailModal', () => ({
) : null,
}));
vi.mock('../guideUtils', async () => {
const actual = await vi.importActual('../guideUtils');
vi.mock('../../utils/guideUtils', async () => {
const actual = await vi.importActual('../../utils/guideUtils');
return {
...actual,
fetchPrograms: vi.fn(),

View file

@ -78,6 +78,13 @@ vi.mock('../../components/forms/settings/NavOrderForm', () => ({
</div>
),
}));
vi.mock('../../components/forms/settings/UserLimitsForm', () => ({
default: ({ active }) => (
<div data-testid="user-limits-form">
UserLimitsForm {active ? 'active' : 'inactive'}
</div>
),
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
}));

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import * as guideUtils from '../guideUtils';
import * as guideUtils from '../../utils/guideUtils';
import * as dateTimeUtils from '../../utils/dateTimeUtils';
import API from '../../api';
@ -676,11 +676,26 @@ describe('guideUtils', () => {
it('should exclude terminal status recordings', () => {
const recordings = [
{ id: 1, custom_properties: { program: { id: 'p1' }, status: 'completed' } },
{ id: 2, custom_properties: { program: { id: 'p2' }, status: 'stopped' } },
{ id: 3, custom_properties: { program: { id: 'p3' }, status: 'interrupted' } },
{ id: 4, custom_properties: { program: { id: 'p4' }, status: 'failed' } },
{ id: 5, custom_properties: { program: { id: 'p5' }, status: 'recording' } },
{
id: 1,
custom_properties: { program: { id: 'p1' }, status: 'completed' },
},
{
id: 2,
custom_properties: { program: { id: 'p2' }, status: 'stopped' },
},
{
id: 3,
custom_properties: { program: { id: 'p3' }, status: 'interrupted' },
},
{
id: 4,
custom_properties: { program: { id: 'p4' }, status: 'failed' },
},
{
id: 5,
custom_properties: { program: { id: 'p5' }, status: 'recording' },
},
{ id: 6, custom_properties: { program: { id: 'p6' } } },
];

View file

@ -14,7 +14,7 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
},
version: {
version: '',
@ -33,7 +33,7 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
});
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(null);
@ -87,7 +87,7 @@ describe('useSettingsStore', () => {
public_ip: '',
country_code: '',
country_name: '',
env_mode: 'prod',
env_mode: 'aio',
});
});

Some files were not shown because too many files have changed in this diff Show more