mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
commit
633b12e2d3
65 changed files with 5754 additions and 2400 deletions
61
CHANGELOG.md
61
CHANGELOG.md
|
|
@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
|
||||
- Updated `Django` 6.0.5 → 6.0.6, resolving the following CVEs:
|
||||
- **CVE-2026-6873**: Signed cookie salt namespace collision in `HttpRequest.get_signed_cookie()`.
|
||||
- **CVE-2026-7666**: Potential unencrypted email transmission via STARTTLS in the SMTP backend.
|
||||
- **CVE-2026-8404**: Potential private data exposure via case-sensitive `Cache-Control` directives in `UpdateCacheMiddleware`.
|
||||
- **CVE-2026-35193**: Potential private data exposure via missing `Vary: Authorization` in `UpdateCacheMiddleware`.
|
||||
- **CVE-2026-48587**: Potential private data exposure via whitespace padding in the `Vary` header.
|
||||
- Updated frontend npm dependencies to resolve 4 audit vulnerabilities (1 low, 2 moderate, 1 high):
|
||||
- Updated `vite` 7.3.2 → 7.3.5, resolving **moderate** NTLMv2 hash disclosure via UNC path handling on Windows ([GHSA-v6wh-96g9-6wx3](https://github.com/advisories/GHSA-v6wh-96g9-6wx3)) and **high** `server.fs.deny` bypass on Windows alternate paths ([GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff))
|
||||
- Updated `js-yaml` 4.1.1 → 5.1.0, resolving **moderate** quadratic-complexity DoS in merge key handling via repeated aliases ([GHSA-h67p-54hq-rp68](https://github.com/advisories/GHSA-h67p-54hq-rp68))
|
||||
- Updated `esbuild` 0.27.3 → 0.28.1, resolving **low** arbitrary file read when running the development server on Windows ([GHSA-g7r4-m6w7-qqqr](https://github.com/advisories/GHSA-g7r4-m6w7-qqqr))
|
||||
|
||||
### Added
|
||||
|
||||
- **Isolated backend test settings (`dispatcharr.settings_test`).** `python manage.py test` now switches to this module automatically (via `manage.py`). It creates an empty PostgreSQL `test_<dbname>` database (same engine as production), uses the standard Postgres backend instead of geventpool so `TestCase` transactions isolate correctly, and leaves Celery tasks queued (no eager `post_save` signal runs during tests). Set `TEST_USE_SQLITE=1` for an in-memory SQLite fallback when Postgres is unavailable.
|
||||
|
||||
### Performance
|
||||
|
||||
- **`get_vod_streams` and `get_series` XC API endpoints are faster and no longer exhaust Docker `/dev/shm`.** Large libraries (e.g. 125k movies) previously ran one wide `DISTINCT ON` query with parallel workers, which could fail with `could not resize shared memory segment … No space left on device` on the default 64MB container shm. Both endpoints now fetch display columns via `.values()` (no ORM model instantiation per row). Redundant `category` and `logo` joins were dropped in favor of FK ids; alphabetical sort runs in SQL. Typical full-library response time drops from ~23–28s to ~8–10s with stable shm usage.
|
||||
- **XMLTV EPG export is faster and no longer balloons worker memory.** `generate_epg()` was reworked end-to-end for large guides. (Fixes #1366)
|
||||
- Streams incrementally: on a cache miss each chunk is pushed to a Redis list as it is yielded (no `''.join()` in the worker); repeat requests within 300s stream chunks back from Redis. `malloc_trim` runs after cold builds.
|
||||
- Channel streams are prefetched once (only `id`/`name`) instead of one query per custom-dummy channel; dummy `EPGData` programme existence is bulk-checked in a single query.
|
||||
- The primary channel id is escaped once per `epg_id` group instead of once per programme (~750k fewer `html.escape` calls on a large guide).
|
||||
- The channel query no longer JOINs multi-MB `programme_index` blobs per channel (~13s saved on a ~2000-channel guide; indices live in `EPGSourceIndex`).
|
||||
- Programme export uses `(epg_id, id)` keyset pagination with a per-source `start_time` sort; a matching composite index on `ProgramData` (created `CONCURRENTLY` on PostgreSQL) lets each chunk use an ordered index range scan instead of re-sorting every chunk.
|
||||
- **EPG grid endpoint releases its payload memory back to the OS.** `/api/epg/grid/` drops the redundant full-list copy when appending dummy programmes and runs `malloc_trim` once the response is sent, so worker RSS no longer ratchets up ~20MB per request.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`programme_index` moved off `EPGSource` into a dedicated `EPGSourceIndex` table.** The multi-MB byte-offset index was repeatedly pulled into web and Celery workers by ordinary `EPGSource` queries and `select_related` JOINs (which ignore manager-level `defer()`). It now lives in a one-to-one `EPGSourceIndex` row, read only when explicitly accessed through the `EPGSource.programme_index` property, so no list, detail, or JOIN query can load it by accident. Migration `0026` copies existing indices across. No API or index-build behavior change.
|
||||
|
||||
- **EPG generation extracted into `apps/output/epg.py`.** All XMLTV output logic (`generate_epg`, `generate_dummy_programs`, `generate_custom_dummy_programs`, `generate_dummy_epg`, and supporting helpers) moved from `apps/output/views.py` into a dedicated module. `views.py` retains the thin HTTP endpoint wrappers and auth checks; `epg.py` handles all content generation. No behavior change.
|
||||
|
||||
- **Channel list with nested streams loads faster.** `GET /api/channels/channels/?include_streams=true` (Channels UI and single-channel fetch) now builds nested stream payloads from the prefetched `channelstream_set` instead of issuing one extra streams M2M query per channel.
|
||||
|
||||
- Dependency updates:
|
||||
- `Django` 6.0.5 → 6.0.6 (security patch; see Security section)
|
||||
- `requests` 2.33.1 → 2.34.2
|
||||
- `gevent` 26.4.0 → 26.5.0
|
||||
- `torch` 2.11.0+cpu → 2.12.1+cpu
|
||||
- `sentence-transformers` 5.4.1 → 5.6.0
|
||||
- `lxml` 6.1.0 → 6.1.1
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Channel Initialization Grace Period is honoured during live stream startup.** Preview and playback no longer abort after a hardcoded 10s while the channel is still connecting with an empty buffer; the TS generator init-wait stall check and upstream health monitor now use the configured `channel_init_grace_period` (same as the server cleanup watchdog) instead of `CONNECTION_TIMEOUT`. (Fixes #1380)
|
||||
- **Channels are marked `active` as soon as the buffer threshold is met and a client is streaming.** Once the initial buffer fills, state is set to `active` immediately when viewers are attached, or `waiting_for_clients` when the buffer is ready but no client is connected yet (e.g. proxy API warmup). The cleanup watchdog no longer waits an extra grace period before promoting to `active`. Proxy settings copy now describes `channel_init_grace_period` as an initialization buffer timeout.
|
||||
- **DVR recording playback auth is complete for native video, HLS segments, and redirects.** Completed recordings use `/file/` with native `<video src>`, which cannot send `Authorization` headers. Playback accepts `?token=` query-param JWT (matching VOD streams), and the floating video player appends the token when assigning native URLs. The explicit HLS URL route (`/api/channels/recordings/.../hls/...`) now registers the same authenticators as the ViewSet `@action` handlers—previously only header JWT applied on that path, so `?token=` failed for native HLS clients. When the request used `?token=`, rewritten playlist segment URLs and `/file/`↔`/hls/` redirects preserve the token; hls.js clients that authenticate via `Authorization` are unchanged. `QueryParamJWTAuthentication` reads `request.query_params` on DRF requests.
|
||||
- **In-progress DVR playback no longer jumps to the live edge.** The floating video player still opens in-progress recordings at the start of the seekable range, but hls.js was configured with `liveMaxLatencyDurationCount: 10`, which forced the playhead forward to "now" shortly after playback began. Live-edge sync is now disabled for recording HLS so users can watch, pause, and scrub from the beginning while the recording continues. (Fixes #1329)
|
||||
- **`refresh_single_m3u_account` no longer re-raises after setting account ERROR.** Matches `refresh_epg_data`: the account status and UI already reflect the failure; Celery no longer marks the task FAILED after the terminal error state is persisted.
|
||||
- **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338)
|
||||
- **EPG refresh recovers DB connections and source status after failures.** `refresh_epg_data` now mirrors the M3U hardening: connection reset at task start/end, retried source load after a poisoned-worker connection reset, `QuerySet.update()` for error status on a clean connection, and a `finally` guard that moves sources stuck in `fetching`/`parsing` to `error`. Programme index builds are skipped when programme parsing fails. `parse_channels_only` now persists the error status when a missing file has no URL to refetch.
|
||||
- **M3U `custom_properties` JSON normalization.** Legacy rows and some API writes could store a JSON-encoded string instead of an object in `custom_properties`, causing refresh, profile sync, and auto-sync to fail with `'str' object has no attribute 'get'`. M3U account, profile, and group-relation models normalize on `save()`; group settings bulk writes and read/merge paths use shared helpers in `core.utils` without re-parsing dict values.
|
||||
- **`POST /api/epg/import/` no longer loads the full EPG source row.** The dummy-source guard uses a narrow existence query instead of `objects.get()`, keeping the import trigger lightweight. Request/response shape is unchanged for the UI, plugins, and external importers.
|
||||
- **XC live playback URL building now normalizes account server URLs.** On-demand live URLs (introduced for Server Group profile rotation in 0.27.0) rebuild `/live/{user}/{pass}/{stream_id}.ts` from current credentials instead of reusing the synced `stream.url`. That path now runs `normalize_server_url()` so pasted API URLs (e.g. `/player_api.php` with query params) are stripped while sub-paths like `/server1` are preserved. `get_transformed_credentials()` normalizes the base URL at the source; VOD movie and episode relations use the same helper instead of constructing a throwaway `XCClient` (which also avoided per-request HTTP session setup). (Fixes #1363)
|
||||
- **Live proxy now releases geventpool DB connections on more paths.** `stream_ts()` calls `close_old_connections()` before `StreamingHttpResponse` so clients waiting on channel init do not keep a pool slot while the generator polls Redis. `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, and `get_connections_left()` release in `finally` blocks after ORM work on stream-manager failover paths that run outside Django's request cycle. TS client disconnect cleanup matches fMP4, and `ChannelService` metadata helpers release after their ORM lookups.
|
||||
- **OpenAPI schema paths for nested M3U profiles and filters no longer contain escaped slashes.** Router regex prefixes used unnecessary `\/`, which drf-spectacular serialized literally into the schema and broke client generators (e.g. oapi-codegen). Runtime URL matching is unchanged. (Fixes #1384)
|
||||
- **Auto-sync range conflict warning no longer flags a group's own channels when using a channel-group override.** The M3U group settings "Range conflict" check compared occupant channel groups against the source group being configured. Auto-sync with `group_override` creates channels in the override target group, so those channels were misclassified as conflicts. The frontend now resolves the effective target group (override target when set, otherwise the source group) for classification. Genuine conflicts—manual channels, other accounts, different groups, or user-pinned numbers—still surface. (Fixes #1331) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
||||
## [0.27.0] - 2026-06-16
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -144,7 +144,19 @@ Untested code is significantly less likely to be merged.
|
|||
|
||||
- Use Django's `TestCase` for unit/integration tests.
|
||||
- Test files live at `apps/<app>/tests/`.
|
||||
- Run the test suite with: `uv run python manage.py test`
|
||||
- Run the backend test suite with:
|
||||
|
||||
```bash
|
||||
python manage.py test
|
||||
```
|
||||
|
||||
`manage.py` automatically uses `dispatcharr.settings_test`, which creates an empty PostgreSQL database `test_<dbname>` (same engine as production), runs migrations, and rolls back each test in a transaction. Your live VOD/channels data is not used.
|
||||
|
||||
Optional: `TEST_USE_SQLITE=1` for machines without Postgres (some PostgreSQL-only tests skip automatically).
|
||||
|
||||
Tests that exercise Celery task bodies should use `@override_settings(CELERY_TASK_ALWAYS_EAGER=True)` locally. Global eager mode is off because `post_save` signals on M3U/EPG models call `.delay()` and would break `TestCase` transaction isolation.
|
||||
|
||||
- Do **not** override with `--settings=dispatcharr.settings` on a live instance.
|
||||
|
||||
### Frontend
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ class QueryParamJWTAuthentication(JWTAuthentication):
|
|||
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = request.GET.get('token')
|
||||
params = getattr(request, "query_params", request.GET)
|
||||
raw_token = params.get("token")
|
||||
if not raw_token:
|
||||
return None
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from .api_views import (
|
|||
UpdateChannelMembershipAPIView,
|
||||
BulkUpdateChannelMembershipAPIView,
|
||||
RecordingViewSet,
|
||||
RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||
RecurringRecordingRuleViewSet,
|
||||
GetChannelStreamsAPIView,
|
||||
GetChannelStreamStatsAPIView,
|
||||
|
|
@ -53,7 +54,10 @@ urlpatterns = [
|
|||
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
|
||||
path(
|
||||
'recordings/<int:pk>/hls/<path:seg_path>',
|
||||
RecordingViewSet.as_view({'get': 'hls'}),
|
||||
RecordingViewSet.as_view(
|
||||
{'get': 'hls'},
|
||||
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||
),
|
||||
name='recording-hls',
|
||||
),
|
||||
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from rest_framework.response import Response
|
|||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
|
|
@ -12,6 +14,7 @@ from django.db import connection, transaction
|
|||
from django.db.models import Count, F, Prefetch
|
||||
from django.db.models import Q
|
||||
import os, json, requests, logging, mimetypes, threading, time
|
||||
from urllib.parse import urlencode
|
||||
from datetime import timedelta
|
||||
from django.utils.http import http_date
|
||||
from apps.accounts.permissions import (
|
||||
|
|
@ -3236,12 +3239,41 @@ def _stop_dvr_clients(channel_uuid, recording_id=None):
|
|||
return stopped
|
||||
|
||||
|
||||
# QueryParamJWTAuthentication supports native <video src> clients that cannot
|
||||
# send Authorization headers. Authorization still requires an authenticated
|
||||
# user via _user_can_play_recording; these classes only populate request.user.
|
||||
RECORDING_PLAYBACK_AUTHENTICATORS = [
|
||||
JWTAuthentication,
|
||||
ApiKeyAuthentication,
|
||||
QueryParamJWTAuthentication,
|
||||
]
|
||||
|
||||
|
||||
def _recording_auth_query_suffix(request):
|
||||
"""Suffix for rewritten recording URLs when auth used ?token= (native <video>).
|
||||
|
||||
hls.js clients authenticate via Authorization on each XHR and do not need
|
||||
tokens embedded in playlist segment lines.
|
||||
"""
|
||||
from rest_framework.request import Request as DRFRequest
|
||||
|
||||
if isinstance(request, DRFRequest):
|
||||
params = request.query_params
|
||||
else:
|
||||
params = request.GET
|
||||
token = params.get("token")
|
||||
if not token:
|
||||
return ""
|
||||
return "?" + urlencode({"token": token})
|
||||
|
||||
|
||||
class RecordingViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recording.objects.all()
|
||||
serializer_class = RecordingSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
# Allow unauthenticated playback of recording files (like other streaming endpoints)
|
||||
# file/hls use AllowAny so DRF does not reject requests before auth
|
||||
# classes run; _user_can_play_recording enforces authenticated access.
|
||||
if self.action in ('file', 'hls'):
|
||||
return [AllowAny()]
|
||||
try:
|
||||
|
|
@ -3305,7 +3337,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
except Exception as e:
|
||||
return Response({"success": False, "error": str(e)}, status=400)
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="file")
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="file",
|
||||
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||
)
|
||||
def file(self, request, pk=None):
|
||||
"""Stream a completed recording file with HTTP Range support for seeking.
|
||||
|
||||
|
|
@ -3329,7 +3366,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
if hls_dir and os.path.isdir(hls_dir):
|
||||
hls_url = request.build_absolute_uri(
|
||||
f"/api/channels/recordings/{pk}/hls/index.m3u8"
|
||||
)
|
||||
) + _recording_auth_query_suffix(request)
|
||||
return HttpResponseRedirect(hls_url)
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
raise Http404("Recording file not found")
|
||||
|
|
@ -3393,7 +3430,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
|
||||
return response
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)")
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_path="hls/(?P<seg_path>.+)",
|
||||
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
|
||||
)
|
||||
def hls(self, request, pk=None, seg_path=None):
|
||||
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
|
||||
|
||||
|
|
@ -3417,9 +3459,10 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
cp = recording.custom_properties or {}
|
||||
file_path = cp.get("file_path")
|
||||
if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0:
|
||||
return HttpResponseRedirect(
|
||||
request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/")
|
||||
)
|
||||
file_url = request.build_absolute_uri(
|
||||
f"/api/channels/recordings/{pk}/file/"
|
||||
) + _recording_auth_query_suffix(request)
|
||||
return HttpResponseRedirect(file_url)
|
||||
raise Http404("HLS content not available for this recording")
|
||||
|
||||
# Security: prevent path traversal outside the HLS directory
|
||||
|
|
@ -3432,16 +3475,18 @@ class RecordingViewSet(viewsets.ModelViewSet):
|
|||
raise Http404(f"HLS file not found: {seg_path}")
|
||||
|
||||
if seg_path.endswith(".m3u8"):
|
||||
# Rewrite relative segment lines to absolute URLs through this API
|
||||
# Rewrite relative segment lines to absolute URLs through this API.
|
||||
# Propagate ?token= only for native <video> clients (see helper).
|
||||
base_url = request.build_absolute_uri(
|
||||
f"/api/channels/recordings/{pk}/hls/"
|
||||
)
|
||||
auth_suffix = _recording_auth_query_suffix(request)
|
||||
lines = []
|
||||
with open(requested) as _f:
|
||||
for line in _f:
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
lines.append(f"{base_url}{stripped}\n")
|
||||
lines.append(f"{base_url}{stripped}{auth_suffix}\n")
|
||||
else:
|
||||
lines.append(line)
|
||||
return HttpResponse("".join(lines), content_type="application/x-mpegURL")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ import logging
|
|||
from django.db import transaction
|
||||
|
||||
from .models import Channel, ChannelOverride, ChannelGroupM3UAccount
|
||||
from apps.m3u.tasks import _custom_properties_as_dict, _next_available_number
|
||||
from apps.m3u.tasks import _next_available_number
|
||||
from core.utils import ensure_custom_properties_dict
|
||||
from core.utils import (
|
||||
acquire_task_lock,
|
||||
natural_sort_key,
|
||||
|
|
@ -37,7 +38,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
def is_compact_group(group_relation):
|
||||
"""Return True if the given ChannelGroupM3UAccount is in compact mode."""
|
||||
cp = _custom_properties_as_dict(group_relation.custom_properties)
|
||||
cp = ensure_custom_properties_dict(group_relation.custom_properties)
|
||||
return bool(cp.get("compact_numbering"))
|
||||
|
||||
|
||||
|
|
@ -72,7 +73,7 @@ def get_group_relation_for_channel(channel):
|
|||
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id=channel.auto_created_by_id
|
||||
):
|
||||
cp = _custom_properties_as_dict(rel.custom_properties)
|
||||
cp = ensure_custom_properties_dict(rel.custom_properties)
|
||||
if str(cp.get("group_override", "")) == target:
|
||||
return rel
|
||||
return None
|
||||
|
|
@ -179,7 +180,7 @@ def assign_compact_numbers_for_channels(channel_ids):
|
|||
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id__in={k[0] for k in unresolved}
|
||||
):
|
||||
cp = _custom_properties_as_dict(rel.custom_properties)
|
||||
cp = ensure_custom_properties_dict(rel.custom_properties)
|
||||
target = cp.get("group_override")
|
||||
if not target:
|
||||
continue
|
||||
|
|
@ -295,7 +296,7 @@ def _repack_inner(group_relation):
|
|||
account_id = group_relation.m3u_account_id
|
||||
group_id = group_relation.channel_group_id
|
||||
|
||||
cp = _custom_properties_as_dict(group_relation.custom_properties)
|
||||
cp = ensure_custom_properties_dict(group_relation.custom_properties)
|
||||
sort_order = cp.get("channel_sort_order") or ""
|
||||
sort_reverse = bool(cp.get("channel_sort_reverse"))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.db import models
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
from core.models import StreamProfile, CoreSettings
|
||||
from core.utils import RedisClient
|
||||
from core.utils import RedisClient, custom_properties_as_dict
|
||||
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
||||
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
||||
import logging
|
||||
|
|
@ -1119,6 +1119,13 @@ class ChannelGroupM3UAccount(models.Model):
|
|||
def __str__(self):
|
||||
return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_properties is not None and not isinstance(
|
||||
self.custom_properties, dict
|
||||
):
|
||||
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Logo(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
|
|
|||
|
|
@ -221,17 +221,6 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
|
|||
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# Accept both dict and JSON string for custom_properties (for backward compatibility)
|
||||
val = data.get("custom_properties")
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
data["custom_properties"] = json.loads(val)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate(self, attrs):
|
||||
# Partial PATCHes only carry submitted fields; fill missing
|
||||
# start/end from the instance so the validator catches a PATCH
|
||||
|
|
@ -562,10 +551,13 @@ class ChannelSerializer(serializers.ModelSerializer):
|
|||
return LogoSerializer(obj.logo).data
|
||||
|
||||
def get_streams(self, obj):
|
||||
"""Retrieve ordered stream IDs for GET requests."""
|
||||
return StreamSerializer(
|
||||
obj.streams.all().order_by("channelstream__order"), many=True
|
||||
).data
|
||||
"""Retrieve ordered streams for GET requests using prefetched channelstream_set."""
|
||||
ordered_streams = [
|
||||
cs.stream
|
||||
for cs in obj.channelstream_set.all()
|
||||
if cs.stream_id is not None
|
||||
]
|
||||
return StreamSerializer(ordered_streams, many=True).data
|
||||
|
||||
def create(self, validated_data):
|
||||
streams = validated_data.pop("streams", [])
|
||||
|
|
|
|||
|
|
@ -504,3 +504,72 @@ class SeriesRuleAPITests(TestCase):
|
|||
def test_bulk_remove_requires_tvg_id_or_title(self):
|
||||
resp = self.client.post(self.bulk_remove_url, {}, format="json")
|
||||
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ChannelListIncludeStreamsQueryTests(TestCase):
|
||||
"""include_streams=true must not issue one stream query per channel."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.channels.models import ChannelStream, Stream
|
||||
from apps.m3u.models import M3UAccount
|
||||
|
||||
self.user = User.objects.create_user(username="list_admin", password="x")
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="list-test-account",
|
||||
account_type="XC",
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
self.group = ChannelGroup.objects.create(name=f"List Group {self.id}")
|
||||
|
||||
def _add_channel_with_stream(self, number):
|
||||
from apps.channels.models import ChannelStream, Stream
|
||||
|
||||
channel = Channel.objects.create(
|
||||
channel_number=float(number),
|
||||
name=f"Channel {number}",
|
||||
channel_group=self.group,
|
||||
)
|
||||
stream = Stream.objects.create(
|
||||
name=f"Stream {number}",
|
||||
url=f"http://example.com/{number}.ts",
|
||||
m3u_account=self.account,
|
||||
)
|
||||
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
|
||||
return channel
|
||||
|
||||
def _query_count_for_list(self):
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = self.client.get(
|
||||
"/api/channels/channels/",
|
||||
{"page": 1, "page_size": 50, "include_streams": "true"},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
def test_include_streams_query_count_stable_as_channels_grow(self):
|
||||
self._add_channel_with_stream(1)
|
||||
self._add_channel_with_stream(2)
|
||||
self._add_channel_with_stream(3)
|
||||
q_small = self._query_count_for_list()
|
||||
|
||||
self._add_channel_with_stream(4)
|
||||
self._add_channel_with_stream(5)
|
||||
self._add_channel_with_stream(6)
|
||||
self._add_channel_with_stream(7)
|
||||
q_large = self._query_count_for_list()
|
||||
|
||||
self.assertEqual(
|
||||
q_small,
|
||||
q_large,
|
||||
"include_streams list should use prefetched channelstream_set, "
|
||||
"not one streams M2M query per channel",
|
||||
)
|
||||
|
|
|
|||
193
apps/channels/tests/test_recording_playback_auth.py
Normal file
193
apps/channels/tests/test_recording_playback_auth.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Tests for DVR recording playback authentication (file/hls endpoints)."""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from apps.channels.api_views import _recording_auth_query_suffix
|
||||
from apps.channels.models import Channel, Recording
|
||||
|
||||
|
||||
def _make_admin():
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
user, _ = User.objects.get_or_create(
|
||||
username="recording_playback_admin",
|
||||
defaults={"user_level": User.UserLevel.ADMIN},
|
||||
)
|
||||
user.user_level = User.UserLevel.ADMIN
|
||||
user.set_password("pass")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@override_settings(ALLOWED_HOSTS=["testserver"])
|
||||
@patch("apps.channels.api_views.network_access_allowed", return_value=True)
|
||||
class RecordingPlaybackAuthTests(TestCase):
|
||||
def setUp(self):
|
||||
self.channel = Channel.objects.create(channel_number=42, name="Playback Auth Channel")
|
||||
self.user = _make_admin()
|
||||
self.client = APIClient()
|
||||
self.tmp = tempfile.NamedTemporaryFile(suffix=".mkv", delete=False)
|
||||
self.tmp.write(b"\x00" * 1024)
|
||||
self.tmp.close()
|
||||
self.hls_dir = tempfile.mkdtemp(prefix="dvr_playback_auth_hls_")
|
||||
with open(os.path.join(self.hls_dir, "index.m3u8"), "w", encoding="utf-8") as playlist:
|
||||
playlist.write("#EXTM3U\n#EXTINF:4.0,\nseg_00001.ts\n")
|
||||
with open(os.path.join(self.hls_dir, "seg_00001.ts"), "wb") as segment:
|
||||
segment.write(b"\x00" * 188)
|
||||
now = timezone.now()
|
||||
self.recording = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "completed",
|
||||
"file_path": self.tmp.name,
|
||||
"file_name": "test.mkv",
|
||||
},
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(self.tmp.name):
|
||||
os.unlink(self.tmp.name)
|
||||
if os.path.isdir(self.hls_dir):
|
||||
shutil.rmtree(self.hls_dir, ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
def _jwt_for(user):
|
||||
return str(RefreshToken.for_user(user).access_token)
|
||||
|
||||
def test_file_requires_authentication(self, _mock_network):
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{self.recording.id}/file/"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_file_accepts_jwt_query_param(self, _mock_network):
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{self.recording.id}/file/",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_file_redirect_to_hls_preserves_token(self, _mock_network):
|
||||
pending = os.path.join(self.hls_dir, "pending.mkv")
|
||||
now = timezone.now()
|
||||
in_progress = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
"file_path": pending,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{in_progress.id}/file/",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("token=", response["Location"])
|
||||
self.assertIn("/hls/index.m3u8", response["Location"])
|
||||
|
||||
def test_hls_playlist_rewrites_segments_with_token_when_present(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.content.decode("utf-8")
|
||||
self.assertIn("token=", body)
|
||||
self.assertIn("seg_00001.ts", body)
|
||||
|
||||
def test_hls_playlist_omits_token_when_not_in_request(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.content.decode("utf-8")
|
||||
self.assertIn("seg_00001.ts", body)
|
||||
self.assertNotIn("token=", body)
|
||||
|
||||
def test_hls_segment_accepts_jwt_query_param(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "recording",
|
||||
"_hls_dir": self.hls_dir,
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/seg_00001.ts",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_hls_redirect_to_file_preserves_token(self, _mock_network):
|
||||
now = timezone.now()
|
||||
hls_rec = Recording.objects.create(
|
||||
channel=self.channel,
|
||||
start_time=now,
|
||||
end_time=now,
|
||||
custom_properties={
|
||||
"status": "completed",
|
||||
"file_path": self.tmp.name,
|
||||
"file_name": "test.mkv",
|
||||
},
|
||||
)
|
||||
token = self._jwt_for(self.user)
|
||||
response = self.client.get(
|
||||
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
|
||||
{"token": token},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("token=", response["Location"])
|
||||
self.assertIn("/file/", response["Location"])
|
||||
|
||||
|
||||
class RecordingAuthQuerySuffixTests(TestCase):
|
||||
def test_empty_when_no_token(self):
|
||||
request = SimpleNamespace(GET={})
|
||||
self.assertEqual(_recording_auth_query_suffix(request), "")
|
||||
|
||||
def test_includes_token_when_present(self):
|
||||
request = SimpleNamespace(GET={"token": "abc123"})
|
||||
self.assertEqual(_recording_auth_query_suffix(request), "?token=abc123")
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""Tests for multi-worker channel teardown coordination."""
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
|
|
@ -692,9 +692,8 @@ class InitWaitAbortTests(TestCase):
|
|||
|
||||
self.assertEqual(generator._init_wait_abort_reason(server, time.time()), "client_gone")
|
||||
|
||||
def test_abort_when_connect_stalled_without_buffer(self):
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=10)
|
||||
def test_abort_when_connect_stalled_without_buffer(self, _mock_grace):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
|
|
@ -707,9 +706,128 @@ class InitWaitAbortTests(TestCase):
|
|||
server.redis_client.hget.return_value = b"connecting"
|
||||
server.redis_client.get.return_value = None
|
||||
|
||||
started = time.time() - (getattr(Config, "CONNECTION_TIMEOUT", 10) + 1)
|
||||
started = time.time() - 11
|
||||
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
|
||||
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
|
||||
def test_no_stall_abort_within_init_grace_period(self, _mock_grace):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
client_manager.clients = {"client-1"}
|
||||
server.client_managers = {CHANNEL_ID: client_manager}
|
||||
buffer = MagicMock()
|
||||
buffer.index = 0
|
||||
server.stream_buffers = {CHANNEL_ID: buffer}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.hget.return_value = b"connecting"
|
||||
server.redis_client.get.return_value = None
|
||||
|
||||
started = time.time() - 15
|
||||
self.assertIsNone(generator._init_wait_abort_reason(server, started))
|
||||
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
|
||||
def test_stall_abort_after_init_grace_period(self, _mock_grace):
|
||||
generator = self._make_generator()
|
||||
server = MagicMock()
|
||||
client_manager = MagicMock()
|
||||
client_manager.clients = {"client-1"}
|
||||
server.client_managers = {CHANNEL_ID: client_manager}
|
||||
buffer = MagicMock()
|
||||
buffer.index = 0
|
||||
server.stream_buffers = {CHANNEL_ID: buffer}
|
||||
server.redis_client = MagicMock()
|
||||
server.redis_client.hget.return_value = b"connecting"
|
||||
server.redis_client.get.return_value = None
|
||||
|
||||
started = time.time() - 31
|
||||
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
|
||||
|
||||
|
||||
class PromoteChannelWhenBufferReadyTests(TestCase):
|
||||
def _mock_proxy(self, redis_client):
|
||||
proxy_server = MagicMock()
|
||||
proxy_server.redis_client = redis_client
|
||||
return patch(
|
||||
"apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance",
|
||||
return_value=proxy_server,
|
||||
), proxy_server
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
||||
def test_buffer_ready_with_clients_becomes_active(self, _mock_chunks):
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
||||
redis.get.return_value = b"4"
|
||||
redis.scard.return_value = 2
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, ChannelState.ACTIVE)
|
||||
proxy_server.update_channel_state.assert_called_once()
|
||||
args = proxy_server.update_channel_state.call_args[0]
|
||||
self.assertEqual(args[1], ChannelState.ACTIVE)
|
||||
self.assertEqual(args[2]["clients_at_activation"], "2")
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
||||
def test_buffer_ready_without_clients_becomes_waiting(self, _mock_chunks):
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
||||
redis.get.return_value = b"5"
|
||||
redis.scard.return_value = 0
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, ChannelState.WAITING_FOR_CLIENTS)
|
||||
proxy_server.update_channel_state.assert_called_once_with(
|
||||
CHANNEL_ID,
|
||||
ChannelState.WAITING_FOR_CLIENTS,
|
||||
{
|
||||
ChannelMetadataField.CONNECTION_READY_TIME: ANY,
|
||||
ChannelMetadataField.BUFFER_CHUNKS: "5",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
||||
def test_buffer_not_ready_does_not_promote(self, _mock_chunks):
|
||||
redis = MagicMock()
|
||||
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
||||
redis.get.return_value = b"2"
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertIsNone(result)
|
||||
proxy_server.update_channel_state.assert_not_called()
|
||||
|
||||
def test_waiting_for_clients_with_clients_becomes_active(self):
|
||||
redis = MagicMock()
|
||||
|
||||
def hget_side_effect(key, field):
|
||||
if field == ChannelMetadataField.STATE:
|
||||
return ChannelState.WAITING_FOR_CLIENTS.encode()
|
||||
if field == ChannelMetadataField.CONNECTION_READY_TIME:
|
||||
return b"1700000000.0"
|
||||
return None
|
||||
|
||||
redis.hget.side_effect = hget_side_effect
|
||||
redis.scard.return_value = 1
|
||||
|
||||
ctx, proxy_server = self._mock_proxy(redis)
|
||||
with ctx:
|
||||
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
||||
|
||||
self.assertEqual(result, ChannelState.ACTIVE)
|
||||
proxy_server.update_channel_state.assert_called_once_with(
|
||||
CHANNEL_ID,
|
||||
ChannelState.ACTIVE,
|
||||
{"clients_at_activation": "1"},
|
||||
)
|
||||
|
||||
|
||||
class UpstreamStopBroadcastTests(TestCase):
|
||||
def _make_server(self):
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
from apps.channels.models import Channel
|
||||
return EPGSource.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).defer(
|
||||
'programme_index'
|
||||
).annotate(
|
||||
has_channels=Exists(
|
||||
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
|
||||
|
|
@ -986,7 +984,9 @@ class EPGGridAPIView(APIView):
|
|||
channel_name=name_to_parse,
|
||||
num_days=1,
|
||||
program_length_hours=4,
|
||||
epg_source=epg_source
|
||||
epg_source=epg_source,
|
||||
export_lookback=one_hour_ago,
|
||||
export_cutoff=twenty_four_hours_later,
|
||||
)
|
||||
|
||||
# Custom dummy should always return data (either from patterns or fallback)
|
||||
|
|
@ -1082,13 +1082,19 @@ class EPGGridAPIView(APIView):
|
|||
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
|
||||
)
|
||||
|
||||
# Combine regular and dummy programs
|
||||
all_programs = list(serialized_programs) + dummy_programs
|
||||
# Combine regular and dummy programs in place to avoid copying the large list
|
||||
serialized_programs.extend(dummy_programs)
|
||||
logger.debug(
|
||||
f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)."
|
||||
f"EPGGridAPIView: Returning {len(serialized_programs)} total programs (including {len(dummy_programs)} dummy programs)."
|
||||
)
|
||||
|
||||
return Response({"data": all_programs}, status=status.HTTP_200_OK)
|
||||
# The grid materializes tens of thousands of program dicts plus the
|
||||
# rendered JSON; trim once the response is sent so worker RSS does not
|
||||
# ratchet up per request.
|
||||
from core.utils import spawn_memory_trim
|
||||
response = Response({"data": serialized_programs}, status=status.HTTP_200_OK)
|
||||
response._resource_closers.append(spawn_memory_trim)
|
||||
return response
|
||||
|
||||
|
||||
# ─────────────────────────────
|
||||
|
|
@ -1119,18 +1125,24 @@ class EPGImportAPIView(APIView):
|
|||
epg_id = request.data.get("id", None)
|
||||
force = bool(request.data.get("force", False))
|
||||
|
||||
# Check if this is a dummy EPG source
|
||||
try:
|
||||
# Reject dummy sources with a narrow existence query, no full row load.
|
||||
if epg_id is not None:
|
||||
from .models import EPGSource
|
||||
epg_source = EPGSource.objects.get(id=epg_id)
|
||||
if epg_source.source_type == 'dummy':
|
||||
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
|
||||
|
||||
if EPGSource.objects.filter(
|
||||
id=epg_id, source_type="dummy"
|
||||
).exists():
|
||||
logger.info(
|
||||
"EPGImportAPIView: Skipping refresh for dummy EPG source %s",
|
||||
epg_id,
|
||||
)
|
||||
return Response(
|
||||
{"success": False, "message": "Dummy EPG sources do not require refreshing."},
|
||||
{
|
||||
"success": False,
|
||||
"message": "Dummy EPG sources do not require refreshing.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except EPGSource.DoesNotExist:
|
||||
pass # Let the task handle the missing source
|
||||
|
||||
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
|
||||
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
|
||||
|
|
@ -1230,10 +1242,9 @@ class CurrentProgramsAPIView(APIView):
|
|||
# Limit to 50 IDs per request
|
||||
epg_data_ids = epg_data_ids[:50]
|
||||
|
||||
# Defer the multi-MB programme_index the JOIN would pull once per row. The lookup reads it via a targeted refresh_from_db
|
||||
epg_data_entries = EPGData.objects.select_related('epg_source').defer(
|
||||
'epg_source__programme_index'
|
||||
).filter(id__in=epg_data_ids)
|
||||
epg_data_entries = EPGData.objects.select_related('epg_source').filter(
|
||||
id__in=epg_data_ids
|
||||
)
|
||||
|
||||
# Batch-fetch current programs for all requested EPG entries in one query
|
||||
db_programs = ProgramData.objects.filter(
|
||||
|
|
|
|||
40
apps/epg/migrations/0025_programdata_epg_id_index.py
Normal file
40
apps/epg/migrations/0025_programdata_epg_id_index.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class AddIndexConcurrentlyIfPostgres(AddIndexConcurrently):
|
||||
"""Create the index CONCURRENTLY on PostgreSQL (no table lock on large
|
||||
tables), falling back to a normal blocking AddIndex on other backends
|
||||
such as the sqlite dev/test fallback."""
|
||||
|
||||
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||
if schema_editor.connection.vendor == 'postgresql':
|
||||
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
||||
else:
|
||||
migrations.AddIndex.database_forwards(
|
||||
self, app_label, schema_editor, from_state, to_state
|
||||
)
|
||||
|
||||
def database_backwards(self, app_label, schema_editor, from_state, to_state):
|
||||
if schema_editor.connection.vendor == 'postgresql':
|
||||
super().database_backwards(app_label, schema_editor, from_state, to_state)
|
||||
else:
|
||||
migrations.AddIndex.database_backwards(
|
||||
self, app_label, schema_editor, from_state, to_state
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('epg', '0024_remove_epgsource_api_key_epgsource_password_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrentlyIfPostgres(
|
||||
model_name='programdata',
|
||||
index=models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
|
||||
),
|
||||
]
|
||||
52
apps/epg/migrations/0026_epgsourceindex.py
Normal file
52
apps/epg/migrations/0026_epgsourceindex.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def copy_index_forward(apps, schema_editor):
|
||||
EPGSource = apps.get_model('epg', 'EPGSource')
|
||||
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
|
||||
rows = list(
|
||||
EPGSource.objects.exclude(programme_index__isnull=True).values_list(
|
||||
'id', 'programme_index'
|
||||
)
|
||||
)
|
||||
for source_id, data in rows:
|
||||
EPGSourceIndex.objects.update_or_create(
|
||||
source_id=source_id, defaults={'data': data}
|
||||
)
|
||||
|
||||
|
||||
def copy_index_backward(apps, schema_editor):
|
||||
EPGSource = apps.get_model('epg', 'EPGSource')
|
||||
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
|
||||
for source_id, data in EPGSourceIndex.objects.values_list('source_id', 'data'):
|
||||
EPGSource.objects.filter(id=source_id).update(programme_index=data)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0025_programdata_epg_id_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EPGSourceIndex',
|
||||
fields=[
|
||||
('source', models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
primary_key=True,
|
||||
related_name='index_record',
|
||||
serialize=False,
|
||||
to='epg.epgsource',
|
||||
)),
|
||||
('data', models.JSONField(blank=True, default=None, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(copy_index_forward, copy_index_backward),
|
||||
migrations.RemoveField(
|
||||
model_name='epgsource',
|
||||
name='programme_index',
|
||||
),
|
||||
]
|
||||
|
|
@ -62,12 +62,6 @@ class EPGSource(models.Model):
|
|||
blank=True,
|
||||
help_text="Last status message, including success results or error information"
|
||||
)
|
||||
programme_index = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Byte-offset index mapping tvg_id to file positions, built after each EPG refresh"
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Time when this source was created"
|
||||
|
|
@ -124,6 +118,37 @@ class EPGSource(models.Model):
|
|||
kwargs['update_fields'].remove('updated_at')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def programme_index(self):
|
||||
"""Byte-offset index for this source, read on demand from the separate
|
||||
EPGSourceIndex table so the multi-MB blob is never pulled into EPGSource
|
||||
queries or select_related JOINs. Returns the stored dict or None."""
|
||||
return (
|
||||
EPGSourceIndex.objects.filter(source_id=self.pk)
|
||||
.values_list('data', flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
class EPGSourceIndex(models.Model):
|
||||
"""Byte-offset programme index for an EPGSource, stored in its own table.
|
||||
|
||||
Kept out of EPGSource so the multi-MB JSON blob is only loaded when read
|
||||
explicitly, never when querying or joining EPGSource rows.
|
||||
"""
|
||||
source = models.OneToOneField(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='index_record',
|
||||
primary_key=True,
|
||||
)
|
||||
data = models.JSONField(null=True, blank=True, default=None)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Programme index for source {self.source_id}"
|
||||
|
||||
|
||||
class EPGData(models.Model):
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=512)
|
||||
|
|
@ -153,6 +178,11 @@ class ProgramData(models.Model):
|
|||
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
|
||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.start_time} - {self.end_time})"
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from core.models import UserAgent, CoreSettings
|
|||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
|
||||
from .models import EPGSource, EPGData, ProgramData, SDScheduleMD5, SDProgramMD5
|
||||
from .models import EPGSource, EPGSourceIndex, EPGData, ProgramData, SDScheduleMD5, SDProgramMD5
|
||||
from core.utils import (
|
||||
acquire_task_lock,
|
||||
is_task_lock_held,
|
||||
|
|
@ -39,6 +39,105 @@ from core.utils import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NON_TERMINAL_REFRESH_STATUSES = frozenset({
|
||||
EPGSource.STATUS_FETCHING,
|
||||
EPGSource.STATUS_PARSING,
|
||||
})
|
||||
|
||||
|
||||
def _release_task_db_connection():
|
||||
"""Return the Celery worker's DB connection to a clean state after ORM errors."""
|
||||
from django.db import close_old_connections
|
||||
close_old_connections()
|
||||
|
||||
|
||||
def _db_query_with_retry(fn, *, label="DB query", max_retries=2):
|
||||
"""
|
||||
Run an ORM read with one connection reset + retry on transient failures.
|
||||
|
||||
Poisoned Celery worker connections often surface as OperationalError or as
|
||||
``IndexError: list index out of range`` inside Django's row converters.
|
||||
"""
|
||||
from django.db import InterfaceError, OperationalError
|
||||
|
||||
transient_errors = (OperationalError, InterfaceError, IndexError)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return fn()
|
||||
except transient_errors as exc:
|
||||
if attempt + 1 >= max_retries:
|
||||
raise
|
||||
logger.warning(
|
||||
"%s failed (%s), resetting DB connection (%s/%s)",
|
||||
label,
|
||||
exc,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
_release_task_db_connection()
|
||||
|
||||
|
||||
def _get_epg_source(source_id):
|
||||
return _db_query_with_retry(
|
||||
lambda: EPGSource.objects.get(id=source_id),
|
||||
label=f"load EPG source {source_id}",
|
||||
)
|
||||
|
||||
|
||||
def _set_epg_source_status(
|
||||
source_id,
|
||||
status,
|
||||
last_message=None,
|
||||
*,
|
||||
notify_error=False,
|
||||
ws_action="refresh",
|
||||
ws_error=None,
|
||||
):
|
||||
"""Update source status using a fresh connection (safe after DB failures)."""
|
||||
_release_task_db_connection()
|
||||
update = {"status": status}
|
||||
if last_message is not None:
|
||||
update["last_message"] = last_message
|
||||
try:
|
||||
EPGSource.objects.filter(id=source_id).update(**update)
|
||||
if notify_error:
|
||||
send_epg_update(
|
||||
source_id,
|
||||
ws_action,
|
||||
100,
|
||||
status="error",
|
||||
error=ws_error or last_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to set EPG source {source_id} status to {status}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_epg_refresh_terminal_status(source_id):
|
||||
"""Mark refresh as failed when the task exits while still in progress."""
|
||||
_release_task_db_connection()
|
||||
try:
|
||||
current_status = (
|
||||
EPGSource.objects.filter(id=source_id)
|
||||
.values_list("status", flat=True)
|
||||
.first()
|
||||
)
|
||||
if current_status in _NON_TERMINAL_REFRESH_STATUSES:
|
||||
message = "Refresh did not complete successfully"
|
||||
EPGSource.objects.filter(id=source_id).update(
|
||||
status=EPGSource.STATUS_ERROR,
|
||||
last_message=message,
|
||||
)
|
||||
send_epg_update(
|
||||
source_id, "refresh", 100, status="error", error=message
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Could not verify terminal refresh status for EPG source {source_id}: {e}"
|
||||
)
|
||||
|
||||
|
||||
SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
|
||||
SD_DAYS_TO_FETCH = 20
|
||||
SD_PROGRAM_BATCH_SIZE = 5000
|
||||
|
|
@ -498,104 +597,92 @@ def refresh_epg_data(source_id, force=False):
|
|||
lock_renewer = TaskLockRenewer('refresh_epg_data', source_id)
|
||||
lock_renewer.start()
|
||||
|
||||
source = None
|
||||
_release_task_db_connection()
|
||||
|
||||
try:
|
||||
# Try to get the EPG source
|
||||
try:
|
||||
source = EPGSource.objects.get(id=source_id)
|
||||
except EPGSource.DoesNotExist:
|
||||
# The EPG source doesn't exist, so delete the periodic task if it exists
|
||||
logger.warning(f"EPG source with ID {source_id} not found, but task was triggered. Cleaning up orphaned task.")
|
||||
|
||||
# Call the shared function to delete the task
|
||||
if delete_epg_refresh_task_by_id(source_id):
|
||||
logger.info(f"Successfully cleaned up orphaned task for EPG source {source_id}")
|
||||
else:
|
||||
logger.info(f"No orphaned task found for EPG source {source_id}")
|
||||
|
||||
# Release the lock and exit
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
return f"EPG source {source_id} does not exist, task cleaned up"
|
||||
|
||||
# The source exists but is not active, just skip processing
|
||||
if not source.is_active:
|
||||
logger.info(f"EPG source {source_id} is not active. Skipping.")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
# Skip refresh for dummy EPG sources - they don't need refreshing
|
||||
if source.source_type == 'dummy':
|
||||
logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
# Continue with the normal processing...
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
# Invalidate the byte-offset index before downloading the new file
|
||||
# so stale offsets are never used during the refresh window.
|
||||
EPGSource.objects.filter(id=source.id).update(programme_index=None)
|
||||
fetch_success = fetch_xmltv(source)
|
||||
if not fetch_success:
|
||||
logger.error(f"Failed to fetch XMLTV for source {source.name}")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
parse_channels_success = parse_channels_only(source)
|
||||
if not parse_channels_success:
|
||||
logger.error(f"Failed to parse channels for source {source.name}")
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
# Force garbage collection before exit
|
||||
gc.collect()
|
||||
return
|
||||
|
||||
# Build byte-offset index after programme data is committed so refresh
|
||||
# does not compete for memory/IO during the programme swap.
|
||||
parse_programs_for_source(source)
|
||||
build_programme_index_task.delay(source.id)
|
||||
|
||||
elif source.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct(source, force=force)
|
||||
|
||||
source.save(update_fields=['updated_at'])
|
||||
# After successful EPG refresh, evaluate DVR series rules to schedule new episodes
|
||||
try:
|
||||
from apps.channels.tasks import evaluate_series_rules
|
||||
evaluate_series_rules.delay()
|
||||
except Exception:
|
||||
pass
|
||||
return _refresh_epg_data_impl(source_id, force=force)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in refresh_epg_data for source {source_id}: {e}", exc_info=True)
|
||||
try:
|
||||
if source:
|
||||
source.status = 'error'
|
||||
source.last_message = f"Error refreshing EPG data: {str(e)}"
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(source_id, "refresh", 100, status="error", error=str(e))
|
||||
except Exception as inner_e:
|
||||
logger.error(f"Error updating source status: {inner_e}")
|
||||
logger.error(
|
||||
f"Error in refresh_epg_data for source {source_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
_set_epg_source_status(
|
||||
source_id,
|
||||
EPGSource.STATUS_ERROR,
|
||||
f"Error refreshing EPG data: {str(e)[:500]}",
|
||||
notify_error=True,
|
||||
ws_error=str(e)[:500],
|
||||
)
|
||||
finally:
|
||||
# Clear references to ensure proper garbage collection
|
||||
source = None
|
||||
# Force garbage collection before releasing the lock
|
||||
_ensure_epg_refresh_terminal_status(source_id)
|
||||
_release_task_db_connection()
|
||||
gc.collect()
|
||||
connection.close()
|
||||
lock_renewer.stop()
|
||||
release_task_lock('refresh_epg_data', source_id)
|
||||
|
||||
|
||||
def _refresh_epg_data_impl(source_id, force=False):
|
||||
try:
|
||||
source = _get_epg_source(source_id)
|
||||
except EPGSource.DoesNotExist:
|
||||
logger.warning(
|
||||
f"EPG source with ID {source_id} not found, but task was triggered. "
|
||||
"Cleaning up orphaned task."
|
||||
)
|
||||
|
||||
if delete_epg_refresh_task_by_id(source_id):
|
||||
logger.info(
|
||||
f"Successfully cleaned up orphaned task for EPG source {source_id}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"No orphaned task found for EPG source {source_id}")
|
||||
|
||||
return f"EPG source {source_id} does not exist, task cleaned up"
|
||||
|
||||
if not source.is_active:
|
||||
logger.info(f"EPG source {source_id} is not active. Skipping.")
|
||||
return
|
||||
|
||||
if source.source_type == 'dummy':
|
||||
logger.info(
|
||||
f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
|
||||
if source.source_type == 'xmltv':
|
||||
# Invalidate the byte-offset index before downloading the new file
|
||||
# so stale offsets are never used during the refresh window.
|
||||
EPGSourceIndex.objects.update_or_create(
|
||||
source_id=source.id, defaults={'data': None}
|
||||
)
|
||||
if not fetch_xmltv(source):
|
||||
logger.error(f"Failed to fetch XMLTV for source {source.name}")
|
||||
return
|
||||
|
||||
if not parse_channels_only(source):
|
||||
logger.error(f"Failed to parse channels for source {source.name}")
|
||||
return
|
||||
|
||||
# Build byte-offset index after programme data is committed so refresh
|
||||
# does not compete for memory/IO during the programme swap.
|
||||
if not parse_programs_for_source(source):
|
||||
logger.error(f"Failed to parse programs for source {source.name}")
|
||||
return
|
||||
|
||||
build_programme_index_task.delay(source.id)
|
||||
|
||||
elif source.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct(source, force=force)
|
||||
|
||||
EPGSource.objects.filter(id=source.id).update(updated_at=timezone.now())
|
||||
try:
|
||||
from apps.channels.tasks import evaluate_series_rules
|
||||
evaluate_series_rules.delay()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def fetch_xmltv(source):
|
||||
# Handle cases with local file but no URL
|
||||
if not source.url and source.file_path and os.path.exists(source.file_path):
|
||||
|
|
@ -1167,7 +1254,15 @@ def parse_channels_only(source):
|
|||
# Update status to error
|
||||
source.status = 'error'
|
||||
source.last_message = f"No URL provided, cannot fetch EPG data"
|
||||
source.save(update_fields=['updated_at'])
|
||||
source.save(update_fields=['status', 'last_message'])
|
||||
send_epg_update(
|
||||
source.id,
|
||||
"parsing_channels",
|
||||
100,
|
||||
status="error",
|
||||
error="No URL provided",
|
||||
)
|
||||
return False
|
||||
|
||||
# Initialize process variable for memory tracking only in debug mode
|
||||
try:
|
||||
|
|
@ -4391,7 +4486,7 @@ def _programme_to_dict(elem, start_time, end_time):
|
|||
def build_programme_index(source_id):
|
||||
"""
|
||||
Scan the XML file with raw binary I/O to build a {tvg_id: [byte_offset, ...]} map.
|
||||
Persists the result to EPGSource.programme_index. Most XMLTV files group programmes
|
||||
Persists the result to the EPGSourceIndex table. Most XMLTV files group programmes
|
||||
by channel, but some split a channel across multiple non-contiguous blocks, so we
|
||||
record block starts up to _OFFSET_CAP and mark only channels that exceed the cap
|
||||
as interleaved.
|
||||
|
|
@ -4480,7 +4575,9 @@ def build_programme_index(source_id):
|
|||
'channels': index,
|
||||
'interleaved_channels': sorted(interleaved_channels),
|
||||
}
|
||||
EPGSource.objects.filter(id=source_id).update(programme_index=result)
|
||||
EPGSourceIndex.objects.update_or_create(
|
||||
source_id=source_id, defaults={'data': result}
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
|
|
@ -4527,9 +4624,8 @@ def find_current_program_for_tvg_id(epg_or_id):
|
|||
return None
|
||||
|
||||
now = timezone.now()
|
||||
# Force a fresh read of the DB-backed index to avoid using stale related-object
|
||||
# state when an EPG refresh invalidates/rebuilds the index concurrently.
|
||||
source.refresh_from_db(fields=['programme_index'])
|
||||
# The property reads the EPGSourceIndex table fresh on each access, so a
|
||||
# concurrent refresh invalidating/rebuilding the index can't serve stale state.
|
||||
index = source.programme_index
|
||||
|
||||
if index is not None:
|
||||
|
|
|
|||
98
apps/epg/tests/test_epg_import_api.py
Normal file
98
apps/epg/tests/test_epg_import_api.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.epg.models import EPGSource, EPGSourceIndex
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
IMPORT_URL = "/api/epg/import/"
|
||||
|
||||
|
||||
class EPGImportAPITests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="epg_import_admin", password="testpass123"
|
||||
)
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_dummy_source_rejected_without_dispatch(self, mock_delay):
|
||||
source = EPGSource.objects.create(
|
||||
name="Dummy EPG",
|
||||
source_type="dummy",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
IMPORT_URL, {"id": source.id}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertFalse(response.data["success"])
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_xmltv_dispatches_without_loading_programme_index(
|
||||
self, mock_delay
|
||||
):
|
||||
source = EPGSource.objects.create(
|
||||
name="Large Index XMLTV",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
EPGSourceIndex.objects.create(
|
||||
source=source,
|
||||
data={
|
||||
"channels": {f"ch.{i}": {"offsets": [0, 100]} for i in range(200)},
|
||||
"interleaved_channels": [],
|
||||
},
|
||||
)
|
||||
mock_delay.reset_mock()
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = self.client.post(
|
||||
IMPORT_URL, {"id": source.id}, format="json"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
self.assertTrue(response.data["success"])
|
||||
mock_delay.assert_called_once_with(source.id, force=False)
|
||||
for query in ctx.captured_queries:
|
||||
self.assertNotIn(
|
||||
"programme_index",
|
||||
query["sql"].lower(),
|
||||
"import trigger should not read programme_index",
|
||||
)
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_missing_source_still_dispatches(self, mock_delay):
|
||||
response = self.client.post(IMPORT_URL, {"id": 99999}, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
mock_delay.assert_called_once_with(99999, force=False)
|
||||
|
||||
@patch("apps.epg.api_views.refresh_epg_data.delay")
|
||||
def test_import_honours_force_flag(self, mock_delay):
|
||||
source = EPGSource.objects.create(
|
||||
name="Force XMLTV",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
mock_delay.reset_mock()
|
||||
|
||||
response = self.client.post(
|
||||
IMPORT_URL,
|
||||
{"id": source.id, "force": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
mock_delay.assert_called_once_with(source.id, force=True)
|
||||
|
|
@ -519,7 +519,6 @@ class FindCurrentProgramTests(TestCase):
|
|||
name="No Index",
|
||||
source_type="xmltv",
|
||||
file_path=FIXTURE_XML,
|
||||
programme_index=None,
|
||||
)
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="channel.current",
|
||||
|
|
|
|||
106
apps/epg/tests/test_refresh_db_recovery.py
Normal file
106
apps/epg/tests/test_refresh_db_recovery.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.epg.tasks import (
|
||||
_db_query_with_retry,
|
||||
_ensure_epg_refresh_terminal_status,
|
||||
_get_epg_source,
|
||||
_release_task_db_connection,
|
||||
refresh_epg_data,
|
||||
)
|
||||
|
||||
|
||||
class DbQueryWithRetryTests(SimpleTestCase):
|
||||
def test_retries_after_index_error_from_poisoned_connection(self):
|
||||
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
|
||||
|
||||
with patch(
|
||||
"apps.epg.tasks._release_task_db_connection"
|
||||
) as mock_release:
|
||||
result = _db_query_with_retry(fn, label="test query")
|
||||
|
||||
self.assertEqual(result, "ok")
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
mock_release.assert_called_once()
|
||||
|
||||
def test_raises_after_exhausting_retries(self):
|
||||
fn = MagicMock(side_effect=IndexError("list index out of range"))
|
||||
|
||||
with patch("apps.epg.tasks._release_task_db_connection"):
|
||||
with self.assertRaises(IndexError):
|
||||
_db_query_with_retry(fn, label="test query", max_retries=2)
|
||||
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
|
||||
|
||||
class RefreshTaskDbStartupTests(SimpleTestCase):
|
||||
@patch("apps.epg.tasks._ensure_epg_refresh_terminal_status")
|
||||
@patch("apps.epg.tasks._refresh_epg_data_impl")
|
||||
@patch("apps.epg.tasks.release_task_lock")
|
||||
@patch("apps.epg.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.epg.tasks.TaskLockRenewer")
|
||||
@patch("apps.epg.tasks._release_task_db_connection")
|
||||
def test_refresh_releases_db_connection_before_impl(
|
||||
self,
|
||||
mock_release,
|
||||
_mock_renewer,
|
||||
_mock_acquire,
|
||||
_mock_release_lock,
|
||||
mock_impl,
|
||||
_mock_ensure_terminal,
|
||||
):
|
||||
call_order = []
|
||||
|
||||
def track_release():
|
||||
call_order.append("release")
|
||||
|
||||
mock_release.side_effect = track_release
|
||||
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
|
||||
|
||||
result = refresh_epg_data(42)
|
||||
|
||||
self.assertEqual(result, "done")
|
||||
self.assertEqual(call_order[:2], ["release", "impl"])
|
||||
|
||||
@patch("apps.epg.tasks.EPGSource")
|
||||
def test_get_epg_source_uses_retry_helper(self, mock_model):
|
||||
mock_source = MagicMock(id=42)
|
||||
mock_model.objects.get.return_value = mock_source
|
||||
|
||||
with patch("apps.epg.tasks._db_query_with_retry") as mock_retry:
|
||||
mock_retry.side_effect = lambda fn, **_: fn()
|
||||
source = _get_epg_source(42)
|
||||
|
||||
mock_retry.assert_called_once()
|
||||
mock_model.objects.get.assert_called_once_with(id=42)
|
||||
self.assertIs(source, mock_source)
|
||||
|
||||
|
||||
class EnsureEpgTerminalStatusTests(SimpleTestCase):
|
||||
@patch("apps.epg.tasks.send_epg_update")
|
||||
@patch("apps.epg.tasks._release_task_db_connection")
|
||||
def test_marks_stuck_fetching_as_error(self, _mock_release, mock_ws):
|
||||
with patch("apps.epg.tasks.EPGSource") as mock_model:
|
||||
mock_model.STATUS_ERROR = "error"
|
||||
qs = MagicMock()
|
||||
mock_model.objects.filter.return_value = qs
|
||||
qs.values_list.return_value.first.return_value = "fetching"
|
||||
|
||||
_ensure_epg_refresh_terminal_status(7)
|
||||
|
||||
qs.update.assert_called_once()
|
||||
mock_ws.assert_called_once()
|
||||
|
||||
@patch("apps.epg.tasks.send_epg_update")
|
||||
@patch("apps.epg.tasks._release_task_db_connection")
|
||||
def test_leaves_success_unchanged(self, _mock_release, mock_ws):
|
||||
with patch("apps.epg.tasks.EPGSource") as mock_model:
|
||||
qs = MagicMock()
|
||||
mock_model.objects.filter.return_value = qs
|
||||
qs.values_list.return_value.first.return_value = "success"
|
||||
|
||||
_ensure_epg_refresh_terminal_status(7)
|
||||
|
||||
qs.update.assert_not_called()
|
||||
mock_ws.assert_not_called()
|
||||
|
|
@ -16,12 +16,12 @@ app_name = "m3u"
|
|||
router = DefaultRouter()
|
||||
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
|
||||
router.register(
|
||||
r"accounts\/(?P<account_id>\d+)\/profiles",
|
||||
r"accounts/(?P<account_id>\d+)/profiles",
|
||||
M3UAccountProfileViewSet,
|
||||
basename="m3u-account-profiles",
|
||||
)
|
||||
router.register(
|
||||
r"accounts\/(?P<account_id>\d+)\/filters",
|
||||
r"accounts/(?P<account_id>\d+)/filters",
|
||||
M3UFilterViewSet,
|
||||
basename="m3u-filters",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
from core.models import UserAgent
|
||||
from core.utils import safe_upload_path
|
||||
from core.utils import safe_upload_path, ensure_custom_properties_dict
|
||||
from apps.channels.models import ChannelGroupM3UAccount
|
||||
from core.serializers import UserAgentSerializer
|
||||
from apps.vod.models import M3UVODCategoryRelation
|
||||
|
|
@ -536,7 +536,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
auto_channel_sync=setting.get("auto_channel_sync", False),
|
||||
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
|
||||
auto_sync_channel_end=setting.get("auto_sync_channel_end"),
|
||||
custom_properties=setting.get("custom_properties", {}),
|
||||
custom_properties=ensure_custom_properties_dict(
|
||||
setting.get("custom_properties")
|
||||
),
|
||||
)
|
||||
for setting in group_settings
|
||||
if setting.get("channel_group")
|
||||
|
|
@ -561,7 +563,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
|
|||
category_id=setting["id"],
|
||||
m3u_account=account,
|
||||
enabled=setting.get("enabled", True),
|
||||
custom_properties=setting.get("custom_properties", {}),
|
||||
custom_properties=ensure_custom_properties_dict(
|
||||
setting.get("custom_properties")
|
||||
),
|
||||
)
|
||||
for setting in category_settings
|
||||
if setting.get("id")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# apps/m3u/forms.py
|
||||
from django import forms
|
||||
from .models import M3UAccount, M3UFilter
|
||||
from core.utils import ensure_custom_properties_dict
|
||||
import re
|
||||
|
||||
class M3UAccountForm(forms.ModelForm):
|
||||
|
|
@ -28,7 +29,9 @@ class M3UAccountForm(forms.ModelForm):
|
|||
|
||||
# Set initial value for enable_vod from custom_properties
|
||||
if self.instance and self.instance.custom_properties:
|
||||
custom_props = self.instance.custom_properties or {}
|
||||
custom_props = self.instance.custom_properties
|
||||
if not isinstance(custom_props, dict):
|
||||
custom_props = ensure_custom_properties_dict(custom_props)
|
||||
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
|
||||
|
||||
def save(self, commit=True):
|
||||
|
|
@ -37,8 +40,9 @@ class M3UAccountForm(forms.ModelForm):
|
|||
# Handle enable_vod field
|
||||
enable_vod = self.cleaned_data.get('enable_vod', False)
|
||||
|
||||
# Parse existing custom_properties
|
||||
custom_props = instance.custom_properties or {}
|
||||
if not isinstance(custom_props, dict):
|
||||
custom_props = ensure_custom_properties_dict(custom_props)
|
||||
|
||||
# Update VOD preference
|
||||
custom_props['enable_vod'] = enable_vod
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from django.dispatch import receiver
|
|||
from apps.channels.models import StreamProfile
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from core.models import CoreSettings, UserAgent
|
||||
from core.utils import custom_properties_as_dict
|
||||
|
||||
CUSTOM_M3U_ACCOUNT_NAME = "custom"
|
||||
|
||||
|
|
@ -124,6 +125,11 @@ class M3UAccount(models.Model):
|
|||
return user_agent
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.custom_properties is not None and not isinstance(
|
||||
self.custom_properties, dict
|
||||
):
|
||||
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||
|
||||
# Prevent auto_now behavior by handling updated_at manually
|
||||
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
|
||||
# Don't modify updated_at for regular updates
|
||||
|
|
@ -263,6 +269,11 @@ class M3UAccountProfile(models.Model):
|
|||
def save(self, *args, **kwargs):
|
||||
"""Auto-sync exp_date from custom_properties for XC accounts on every save.
|
||||
For non-XC accounts, exp_date is set directly and left untouched here."""
|
||||
if self.custom_properties is not None and not isinstance(
|
||||
self.custom_properties, dict
|
||||
):
|
||||
self.custom_properties = custom_properties_as_dict(self.custom_properties)
|
||||
|
||||
parsed = self._parse_exp_date_from_custom_properties()
|
||||
if parsed is not None:
|
||||
# XC account with exp_date in custom_properties — always sync
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, ensure_custom_properties_dict
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.response import Response
|
||||
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
|
||||
|
|
@ -270,11 +270,15 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
# overwrite their corresponding keys; clients should set those via
|
||||
# the typed top-level fields rather than the custom_properties
|
||||
# payload.
|
||||
incoming_custom = validated_data.get("custom_properties") or {}
|
||||
custom_props = {
|
||||
**(instance.custom_properties or {}),
|
||||
**incoming_custom,
|
||||
}
|
||||
incoming_custom = {}
|
||||
if "custom_properties" in validated_data:
|
||||
incoming_custom = validated_data["custom_properties"] or {}
|
||||
if not isinstance(incoming_custom, dict):
|
||||
incoming_custom = ensure_custom_properties_dict(incoming_custom)
|
||||
existing_custom = instance.custom_properties or {}
|
||||
if not isinstance(existing_custom, dict):
|
||||
existing_custom = ensure_custom_properties_dict(existing_custom)
|
||||
custom_props = {**existing_custom, **incoming_custom}
|
||||
|
||||
if enable_vod is not None:
|
||||
custom_props["enable_vod"] = enable_vod
|
||||
|
|
@ -346,7 +350,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
|
|||
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
|
||||
|
||||
# Parse existing custom_properties or create new
|
||||
custom_props = validated_data.get("custom_properties", {})
|
||||
custom_props = validated_data.get("custom_properties") or {}
|
||||
if not isinstance(custom_props, dict):
|
||||
custom_props = ensure_custom_properties_dict(custom_props)
|
||||
|
||||
# Set preferences (default to True for auto_enable_new_groups)
|
||||
custom_props["enable_vod"] = enable_vod
|
||||
|
|
|
|||
|
|
@ -7,29 +7,23 @@ import os
|
|||
import gc
|
||||
import gzip, zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from celery.app.control import Inspect
|
||||
from celery.result import AsyncResult
|
||||
from celery import shared_task, current_app, group
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import models, transaction
|
||||
from .models import M3UAccount
|
||||
from apps.channels.models import Stream, ChannelGroup, ChannelGroupM3UAccount
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.utils import timezone
|
||||
import time
|
||||
import json
|
||||
from core.utils import (
|
||||
RedisClient,
|
||||
acquire_task_lock,
|
||||
release_task_lock,
|
||||
TaskLockRenewer,
|
||||
natural_sort_key,
|
||||
log_system_event,
|
||||
ensure_custom_properties_dict,
|
||||
)
|
||||
from core.models import CoreSettings, UserAgent
|
||||
from asgiref.sync import async_to_sync
|
||||
from core.xtream_codes import Client as XCClient
|
||||
from core.utils import send_websocket_update
|
||||
from .utils import normalize_stream_url
|
||||
|
|
@ -39,6 +33,104 @@ logger = logging.getLogger(__name__)
|
|||
BATCH_SIZE = 1500 # Optimized batch size for threading
|
||||
m3u_dir = os.path.join(settings.MEDIA_ROOT, "cached_m3u")
|
||||
|
||||
_NON_TERMINAL_REFRESH_STATUSES = frozenset({
|
||||
M3UAccount.Status.FETCHING,
|
||||
M3UAccount.Status.PARSING,
|
||||
})
|
||||
|
||||
|
||||
def _release_task_db_connection():
|
||||
"""Return the Celery worker's DB connection to a clean state after ORM errors."""
|
||||
from django.db import close_old_connections
|
||||
close_old_connections()
|
||||
|
||||
|
||||
def _db_query_with_retry(fn, *, label="DB query", max_retries=2):
|
||||
"""
|
||||
Run an ORM read with one connection reset + retry on transient failures.
|
||||
|
||||
Poisoned Celery worker connections often surface as OperationalError or as
|
||||
``IndexError: list index out of range`` inside Django's row converters.
|
||||
"""
|
||||
from django.db import InterfaceError, OperationalError
|
||||
|
||||
transient_errors = (OperationalError, InterfaceError, IndexError)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return fn()
|
||||
except transient_errors as exc:
|
||||
if attempt + 1 >= max_retries:
|
||||
raise
|
||||
logger.warning(
|
||||
"%s failed (%s), resetting DB connection (%s/%s)",
|
||||
label,
|
||||
exc,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
_release_task_db_connection()
|
||||
|
||||
|
||||
def _get_active_m3u_account(account_id):
|
||||
return _db_query_with_retry(
|
||||
lambda: M3UAccount.objects.get(id=account_id, is_active=True),
|
||||
label=f"load active M3U account {account_id}",
|
||||
)
|
||||
|
||||
|
||||
def _set_m3u_account_status(
|
||||
account_id,
|
||||
status,
|
||||
last_message=None,
|
||||
*,
|
||||
notify_error=False,
|
||||
ws_action="parsing",
|
||||
ws_error=None,
|
||||
):
|
||||
"""Update account status using a fresh connection (safe after DB failures)."""
|
||||
_release_task_db_connection()
|
||||
update = {"status": status}
|
||||
if last_message is not None:
|
||||
update["last_message"] = last_message
|
||||
try:
|
||||
M3UAccount.objects.filter(id=account_id).update(**update)
|
||||
if notify_error:
|
||||
send_m3u_update(
|
||||
account_id,
|
||||
ws_action,
|
||||
100,
|
||||
status="error",
|
||||
error=ws_error or last_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to set account {account_id} status to {status}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_m3u_refresh_terminal_status(account_id):
|
||||
"""Mark refresh as failed when the task exits while still in progress."""
|
||||
_release_task_db_connection()
|
||||
try:
|
||||
current_status = (
|
||||
M3UAccount.objects.filter(id=account_id)
|
||||
.values_list("status", flat=True)
|
||||
.first()
|
||||
)
|
||||
if current_status in _NON_TERMINAL_REFRESH_STATUSES:
|
||||
message = "Refresh did not complete successfully"
|
||||
M3UAccount.objects.filter(id=account_id).update(
|
||||
status=M3UAccount.Status.ERROR,
|
||||
last_message=message,
|
||||
)
|
||||
send_m3u_update(
|
||||
account_id, "parsing", 100, status="error", error=message
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Could not verify terminal refresh status for account {account_id}: {e}"
|
||||
)
|
||||
|
||||
_EXTINF_ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2')
|
||||
|
||||
|
||||
|
|
@ -630,7 +722,7 @@ def process_groups(account, groups, scan_start_time=None):
|
|||
logger.info(f"Currently {len(existing_groups)} existing groups")
|
||||
|
||||
# Check if we should auto-enable new groups based on account settings
|
||||
account_custom_props = account.custom_properties or {}
|
||||
account_custom_props = ensure_custom_properties_dict(account.custom_properties)
|
||||
auto_enable_new_groups_live = account_custom_props.get("auto_enable_new_groups_live", True)
|
||||
|
||||
# Separate existing groups from groups that need to be created
|
||||
|
|
@ -673,7 +765,9 @@ def process_groups(account, groups, scan_start_time=None):
|
|||
existing_rel = existing_relationships[group.name]
|
||||
|
||||
# Get existing custom properties (now JSONB, no need to parse)
|
||||
existing_custom_props = existing_rel.custom_properties or {}
|
||||
existing_custom_props = ensure_custom_properties_dict(
|
||||
existing_rel.custom_properties
|
||||
)
|
||||
|
||||
# Get the new xc_id from groups data
|
||||
new_xc_id = custom_props.get("xc_id")
|
||||
|
|
@ -696,6 +790,8 @@ def process_groups(account, groups, scan_start_time=None):
|
|||
logger.debug(f"Updated xc_id for group '{group.name}' from '{existing_xc_id}' to '{new_xc_id}' - account {account.id}")
|
||||
else:
|
||||
# Update last_seen even if xc_id hasn't changed
|
||||
if isinstance(existing_rel.custom_properties, str):
|
||||
existing_rel.custom_properties = existing_custom_props
|
||||
existing_rel.last_seen = scan_start_time
|
||||
existing_rel.is_stale = False
|
||||
relations_to_update.append(existing_rel)
|
||||
|
|
@ -1755,33 +1851,6 @@ def _range_exhausted_error(mode, start_number, end_number, fallback_start):
|
|||
return f"Channel number range {range_start}-{int(end_number)} is full"
|
||||
|
||||
|
||||
def _custom_properties_as_dict(value):
|
||||
"""
|
||||
Normalize a JSONField-backed custom_properties value into a dict.
|
||||
|
||||
Historical data has rows where the field holds a JSON-encoded string
|
||||
instead of a dict. Django's JSONField serializes whatever it gets, so
|
||||
`.get()` on one of those rows raises AttributeError and aborts the
|
||||
entire sync. Treat string values as JSON to parse, and fall back to an
|
||||
empty dict for anything that isn't a dict after parsing.
|
||||
"""
|
||||
import json
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"custom_properties stored as non-JSON string; ignoring: %r",
|
||||
value[:100],
|
||||
)
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _classify_sync_failure(exc):
|
||||
"""
|
||||
Map an exception raised during per-stream sync to a coarse typed
|
||||
|
|
@ -1890,7 +1959,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg)
|
||||
channel_numbering_mode = "fixed" # Default mode
|
||||
channel_numbering_fallback = 1 # Default fallback for provider mode
|
||||
group_custom_props = _custom_properties_as_dict(
|
||||
group_custom_props = ensure_custom_properties_dict(
|
||||
group_relation.custom_properties
|
||||
)
|
||||
if group_custom_props:
|
||||
|
|
@ -2734,7 +2803,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
|
|||
# "preserve_customized" keeps those with a ChannelOverride row;
|
||||
# "never" disables cleanup. Hidden channels are preserved across all
|
||||
# modes so event/PPV channels that come and go are not silently lost.
|
||||
cleanup_mode = (account.custom_properties or {}).get(
|
||||
cleanup_mode = ensure_custom_properties_dict(account.custom_properties).get(
|
||||
"orphan_channel_cleanup", "always"
|
||||
)
|
||||
if cleanup_mode != "never":
|
||||
|
|
@ -2816,12 +2885,13 @@ def get_transformed_credentials(account, profile=None):
|
|||
logger.warning(f"Could not get primary profile for account {account.name}: {e}")
|
||||
profile = None
|
||||
|
||||
base_url = account.server_url
|
||||
from core.xtream_codes import normalize_server_url
|
||||
|
||||
base_url = normalize_server_url(account.server_url)
|
||||
base_username = account.username
|
||||
base_password = account.password # Build a complete URL with credentials (similar to how IPTV URLs are structured)
|
||||
# Format: http://server.com:port/live/username/password/1234.ts
|
||||
if base_url and base_username and base_password:
|
||||
# Remove trailing slash from server URL if present
|
||||
clean_server_url = base_url.rstrip('/')
|
||||
|
||||
# Build the complete URL with embedded credentials
|
||||
|
|
@ -2947,12 +3017,19 @@ def refresh_account_profiles(account_id):
|
|||
if profile_client.authenticate():
|
||||
# Get account information specific to this profile's credentials
|
||||
profile_account_info = profile_client.get_account_info()
|
||||
if not isinstance(profile_account_info, dict):
|
||||
raise TypeError(
|
||||
f"Unexpected account info type: {type(profile_account_info).__name__}"
|
||||
)
|
||||
|
||||
# Merge with existing custom_properties if they exist
|
||||
existing_props = profile.custom_properties or {}
|
||||
existing_props.update(profile_account_info)
|
||||
profile.custom_properties = existing_props
|
||||
profile.save(update_fields=['custom_properties'])
|
||||
profile.custom_properties = {
|
||||
**ensure_custom_properties_dict(
|
||||
profile.custom_properties
|
||||
),
|
||||
**profile_account_info,
|
||||
}
|
||||
profile.save(update_fields=['custom_properties', 'exp_date'])
|
||||
|
||||
profiles_updated += 1
|
||||
logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})")
|
||||
|
|
@ -2963,6 +3040,7 @@ def refresh_account_profiles(account_id):
|
|||
except Exception as profile_error:
|
||||
profiles_failed += 1
|
||||
logger.error(f"Failed to update account information for profile '{profile.name}': {str(profile_error)}")
|
||||
_release_task_db_connection()
|
||||
# Continue with other profiles even if one fails
|
||||
|
||||
result_msg = f"Profile refresh complete for account {account.name}: {profiles_updated} updated, {profiles_failed} failed"
|
||||
|
|
@ -2977,6 +3055,8 @@ def refresh_account_profiles(account_id):
|
|||
error_msg = f"Error refreshing profiles for account {account_id}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
finally:
|
||||
_release_task_db_connection()
|
||||
|
||||
|
||||
@shared_task
|
||||
|
|
@ -3031,10 +3111,11 @@ def refresh_account_info(profile_id):
|
|||
account_info = client.get_account_info()
|
||||
|
||||
# Update only this specific profile with the new account info
|
||||
if not profile.custom_properties:
|
||||
profile.custom_properties = {}
|
||||
profile.custom_properties.update(account_info)
|
||||
profile.save()
|
||||
profile.custom_properties = {
|
||||
**ensure_custom_properties_dict(profile.custom_properties),
|
||||
**account_info,
|
||||
}
|
||||
profile.save(update_fields=['custom_properties', 'exp_date'])
|
||||
|
||||
# Send success notification to frontend via websocket
|
||||
send_websocket_update(
|
||||
|
|
@ -3097,10 +3178,25 @@ def refresh_single_m3u_account(account_id):
|
|||
lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id)
|
||||
lock_renewer.start()
|
||||
|
||||
_release_task_db_connection()
|
||||
|
||||
try:
|
||||
return _refresh_single_m3u_account_impl(account_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"refresh_single_m3u_account failed for account {account_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
_set_m3u_account_status(
|
||||
account_id,
|
||||
M3UAccount.Status.ERROR,
|
||||
f"Error processing M3U: {str(e)[:500]}",
|
||||
notify_error=True,
|
||||
ws_error=str(e)[:500],
|
||||
)
|
||||
finally:
|
||||
# Guaranteed cleanup on all exit paths (success, exception, early return)
|
||||
_ensure_m3u_refresh_terminal_status(account_id)
|
||||
_release_task_db_connection()
|
||||
lock_renewer.stop()
|
||||
release_task_lock("refresh_single_m3u_account", account_id)
|
||||
|
||||
|
|
@ -3115,22 +3211,25 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
streams_deleted = 0
|
||||
|
||||
try:
|
||||
account = M3UAccount.objects.get(id=account_id, is_active=True)
|
||||
account = _get_active_m3u_account(account_id)
|
||||
if not account.is_active:
|
||||
logger.debug(f"Account {account_id} is not active, skipping.")
|
||||
return
|
||||
|
||||
# Set status to fetching
|
||||
account.status = M3UAccount.Status.FETCHING
|
||||
account.save(update_fields=['status'])
|
||||
# Set status to fetching and replace stale completion messages.
|
||||
_set_m3u_account_status(
|
||||
account_id,
|
||||
M3UAccount.Status.FETCHING,
|
||||
"Refresh in progress...",
|
||||
)
|
||||
account = _get_active_m3u_account(account_id)
|
||||
|
||||
filters = list(account.filters.all())
|
||||
|
||||
# Check if VOD is enabled for this account
|
||||
vod_enabled = False
|
||||
if account.custom_properties:
|
||||
custom_props = account.custom_properties or {}
|
||||
vod_enabled = custom_props.get('enable_vod', False)
|
||||
vod_enabled = ensure_custom_properties_dict(account.custom_properties).get(
|
||||
'enable_vod', False
|
||||
)
|
||||
|
||||
except M3UAccount.DoesNotExist:
|
||||
# The M3U account doesn't exist, so delete the periodic task if it exists
|
||||
|
|
@ -3196,6 +3295,16 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
logger.error(
|
||||
f"Failed to refresh M3U groups for account {account_id}: {result}"
|
||||
)
|
||||
error_msg = (
|
||||
"Failed to refresh M3U groups - download failed or other error"
|
||||
)
|
||||
_set_m3u_account_status(
|
||||
account_id,
|
||||
M3UAccount.Status.ERROR,
|
||||
error_msg,
|
||||
notify_error=True,
|
||||
ws_error=error_msg,
|
||||
)
|
||||
return "Failed to update m3u account - download failed or other error"
|
||||
|
||||
extinf_data, groups = result
|
||||
|
|
@ -3210,23 +3319,23 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
# For XC accounts, empty extinf_data is normal at this stage
|
||||
if not extinf_data and not is_xc_account:
|
||||
logger.error(f"No streams found for non-XC account {account_id}")
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = "No streams found in M3U source"
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
account_id, "parsing", 100, status="error", error="No streams found"
|
||||
error_msg = "No streams found in M3U source"
|
||||
_set_m3u_account_status(
|
||||
account_id,
|
||||
M3UAccount.Status.ERROR,
|
||||
error_msg,
|
||||
notify_error=True,
|
||||
ws_error=error_msg,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception in refresh_m3u_groups: {str(e)}", exc_info=True)
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error refreshing M3U groups: {str(e)}"
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
error_msg = f"Error refreshing M3U groups: {str(e)[:500]}"
|
||||
_set_m3u_account_status(
|
||||
account_id,
|
||||
"parsing",
|
||||
100,
|
||||
status="error",
|
||||
error=f"Error refreshing M3U groups: {str(e)}",
|
||||
M3UAccount.Status.ERROR,
|
||||
error_msg,
|
||||
notify_error=True,
|
||||
ws_error=error_msg,
|
||||
)
|
||||
return "Failed to update m3u account"
|
||||
|
||||
|
|
@ -3240,15 +3349,13 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
# Modified validation logic for different account types
|
||||
if (not groups) or (not is_xc_account and not extinf_data):
|
||||
logger.error(f"No data to process for account {account_id}")
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = "No data available for processing"
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
send_m3u_update(
|
||||
error_msg = "No data available for processing"
|
||||
_set_m3u_account_status(
|
||||
account_id,
|
||||
"parsing",
|
||||
100,
|
||||
status="error",
|
||||
error="No data available for processing",
|
||||
M3UAccount.Status.ERROR,
|
||||
error_msg,
|
||||
notify_error=True,
|
||||
ws_error=error_msg,
|
||||
)
|
||||
return "Failed to update m3u account, no data available"
|
||||
|
||||
|
|
@ -3364,7 +3471,7 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
group_id = rel.channel_group.id
|
||||
|
||||
# Load the custom properties with the xc_id
|
||||
custom_props = rel.custom_properties or {}
|
||||
custom_props = ensure_custom_properties_dict(rel.custom_properties)
|
||||
if "xc_id" in custom_props:
|
||||
filtered_groups[group_name] = {
|
||||
"xc_id": custom_props["xc_id"],
|
||||
|
|
@ -3589,13 +3696,7 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing M3U for account {account_id}: {str(e)}")
|
||||
try:
|
||||
account.status = M3UAccount.Status.ERROR
|
||||
account.last_message = f"Error processing M3U: {str(e)}"
|
||||
account.save(update_fields=["status", "last_message"])
|
||||
except Exception:
|
||||
logger.debug(f"Failed to update account {account_id} status during error handling")
|
||||
raise # Re-raise the exception for Celery to handle
|
||||
raise
|
||||
finally:
|
||||
# Free large data structures regardless of success or failure
|
||||
if 'existing_groups' in locals():
|
||||
|
|
@ -3622,6 +3723,8 @@ def _refresh_single_m3u_account_impl(account_id):
|
|||
except OSError:
|
||||
pass
|
||||
|
||||
_release_task_db_connection()
|
||||
|
||||
return f"Dispatched jobs complete."
|
||||
|
||||
|
||||
|
|
|
|||
74
apps/m3u/tests/test_refresh_db_recovery.py
Normal file
74
apps/m3u/tests/test_refresh_db_recovery.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.m3u.tasks import (
|
||||
_db_query_with_retry,
|
||||
_get_active_m3u_account,
|
||||
_release_task_db_connection,
|
||||
refresh_single_m3u_account,
|
||||
)
|
||||
|
||||
|
||||
class DbQueryWithRetryTests(SimpleTestCase):
|
||||
def test_retries_after_index_error_from_poisoned_connection(self):
|
||||
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
|
||||
|
||||
with patch(
|
||||
"apps.m3u.tasks._release_task_db_connection"
|
||||
) as mock_release:
|
||||
result = _db_query_with_retry(fn, label="test query")
|
||||
|
||||
self.assertEqual(result, "ok")
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
mock_release.assert_called_once()
|
||||
|
||||
def test_raises_after_exhausting_retries(self):
|
||||
fn = MagicMock(side_effect=IndexError("list index out of range"))
|
||||
|
||||
with patch("apps.m3u.tasks._release_task_db_connection"):
|
||||
with self.assertRaises(IndexError):
|
||||
_db_query_with_retry(fn, label="test query", max_retries=2)
|
||||
|
||||
self.assertEqual(fn.call_count, 2)
|
||||
|
||||
|
||||
class RefreshTaskDbStartupTests(SimpleTestCase):
|
||||
@patch("apps.m3u.tasks._ensure_m3u_refresh_terminal_status")
|
||||
@patch("apps.m3u.tasks._refresh_single_m3u_account_impl")
|
||||
@patch("apps.m3u.tasks.release_task_lock")
|
||||
@patch("apps.m3u.tasks.acquire_task_lock", return_value=True)
|
||||
@patch("apps.m3u.tasks.TaskLockRenewer")
|
||||
@patch("apps.m3u.tasks._release_task_db_connection")
|
||||
def test_refresh_releases_db_connection_before_impl(
|
||||
self,
|
||||
mock_release,
|
||||
_mock_renewer,
|
||||
_mock_acquire,
|
||||
_mock_release_lock,
|
||||
mock_impl,
|
||||
_mock_ensure_terminal,
|
||||
):
|
||||
call_order = []
|
||||
|
||||
def track_release():
|
||||
call_order.append("release")
|
||||
|
||||
mock_release.side_effect = track_release
|
||||
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
|
||||
|
||||
result = refresh_single_m3u_account(140)
|
||||
|
||||
self.assertEqual(result, "done")
|
||||
self.assertEqual(call_order[:2], ["release", "impl"])
|
||||
|
||||
@patch("apps.m3u.tasks.M3UAccount")
|
||||
def test_get_active_m3u_account_uses_retry_helper(self, mock_model):
|
||||
mock_model.objects.get.return_value = MagicMock(is_active=True)
|
||||
|
||||
with patch("apps.m3u.tasks._db_query_with_retry") as mock_retry:
|
||||
mock_retry.side_effect = lambda fn, **_: fn()
|
||||
account = _get_active_m3u_account(140)
|
||||
|
||||
mock_retry.assert_called_once()
|
||||
self.assertTrue(account.is_active)
|
||||
|
|
@ -644,6 +644,34 @@ class NumbersInRangeLookupTests(TestCase):
|
|||
)
|
||||
self.assertFalse(occupant["has_channel_number_override"])
|
||||
|
||||
def test_group_override_channel_reports_target_group(self):
|
||||
# When auto-sync routes channels into a different group via
|
||||
# group_override, the occupant's channel_group_id is the override
|
||||
# target, not the source group being configured. The frontend relies
|
||||
# on this to recognize override-routed channels as the config's own
|
||||
# output (effectiveSyncGroupId), so the warning does not flag them.
|
||||
account = _make_account()
|
||||
source = _make_group(name="SourceGrp")
|
||||
target = _make_group(name="TargetGrp")
|
||||
Channel.objects.create(
|
||||
name="Routed",
|
||||
channel_number=3210,
|
||||
channel_group=target,
|
||||
auto_created=True,
|
||||
auto_created_by=account,
|
||||
)
|
||||
client = self._client()
|
||||
|
||||
response = client.get(
|
||||
"/api/channels/channels/numbers-in-range/?start=3210&end=3210"
|
||||
)
|
||||
|
||||
occupant = response.data["occupants"][0]
|
||||
self.assertEqual(occupant["channel_group_id"], target.id)
|
||||
self.assertNotEqual(occupant["channel_group_id"], source.id)
|
||||
self.assertTrue(occupant["auto_created"])
|
||||
self.assertEqual(occupant["auto_created_by_account_id"], account.id)
|
||||
|
||||
def test_manual_channel_exposed_with_auto_created_false(self):
|
||||
# Manual channels are always a real collision worth surfacing.
|
||||
# The response must flag them with auto_created=False and a null
|
||||
|
|
|
|||
139
apps/m3u/tests/test_xc_live_url.py
Normal file
139
apps/m3u/tests/test_xc_live_url.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Tests for XC stream URL normalization and on-demand URL building."""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.models import Stream
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from apps.m3u.tasks import get_transformed_credentials
|
||||
from apps.proxy.live_proxy.url_utils import _resolve_live_stream_url
|
||||
from apps.vod.models import Episode, M3UEpisodeRelation, M3UMovieRelation, Movie, Series
|
||||
from core.xtream_codes import normalize_server_url
|
||||
|
||||
|
||||
class NormalizeServerUrlTests(TestCase):
|
||||
def test_preserves_sub_path(self):
|
||||
url = "https://myserver.fun/server1"
|
||||
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||
|
||||
def test_strips_player_api_php_and_query_params(self):
|
||||
url = "https://myserver.fun/server1/player_api.php?username=foo&password=bar"
|
||||
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||
|
||||
def test_strips_trailing_slash(self):
|
||||
url = "https://myserver.fun/server1/"
|
||||
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
|
||||
|
||||
def test_nested_sub_path_with_php_endpoint(self):
|
||||
url = "http://server/Pluto/gb/player_api.php"
|
||||
self.assertEqual(normalize_server_url(url), "http://server/Pluto/gb")
|
||||
|
||||
|
||||
class GetTransformedCredentialsTests(TestCase):
|
||||
def test_returns_normalized_server_url(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="Sub-path XC",
|
||||
account_type="XC",
|
||||
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
|
||||
server_url, username, password = get_transformed_credentials(account, profile)
|
||||
|
||||
self.assertEqual(server_url, "https://myserver.fun/server1")
|
||||
self.assertEqual(username, "alice")
|
||||
self.assertEqual(password, "secret")
|
||||
|
||||
|
||||
class ResolveLiveStreamUrlTests(TestCase):
|
||||
def test_builds_url_from_normalized_base_not_raw_account_url(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="Live sub-path",
|
||||
account_type="XC",
|
||||
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
stream = Stream.objects.create(
|
||||
name="Test Channel",
|
||||
m3u_account=account,
|
||||
stream_id="12345",
|
||||
url="https://myserver.fun/server1/live/olduser/oldpass/12345.ts",
|
||||
)
|
||||
|
||||
url = _resolve_live_stream_url(stream, account, profile)
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://myserver.fun/server1/live/alice/secret/12345.ts",
|
||||
)
|
||||
|
||||
def test_std_account_uses_stored_stream_url(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name="STD account",
|
||||
account_type="STD",
|
||||
server_url="https://example.com/list.m3u",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
|
||||
stream = Stream.objects.create(
|
||||
name="STD Stream",
|
||||
m3u_account=account,
|
||||
url="https://provider.example/stream/abc123",
|
||||
)
|
||||
|
||||
url = _resolve_live_stream_url(stream, account, profile)
|
||||
|
||||
self.assertEqual(url, "https://provider.example/stream/abc123")
|
||||
|
||||
|
||||
class VodStreamUrlTests(TestCase):
|
||||
def setUp(self):
|
||||
self.account = M3UAccount.objects.create(
|
||||
name="VOD sub-path",
|
||||
account_type="XC",
|
||||
server_url="https://myserver.fun/server1/player_api.php?username=foo",
|
||||
username="alice",
|
||||
password="secret",
|
||||
)
|
||||
|
||||
def test_movie_relation_builds_normalized_url(self):
|
||||
movie = Movie.objects.create(name="Test Movie")
|
||||
relation = M3UMovieRelation.objects.create(
|
||||
m3u_account=self.account,
|
||||
movie=movie,
|
||||
stream_id="999",
|
||||
container_extension="mkv",
|
||||
)
|
||||
|
||||
url = relation.get_stream_url()
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://myserver.fun/server1/movie/alice/secret/999.mkv",
|
||||
)
|
||||
|
||||
def test_episode_relation_builds_normalized_url(self):
|
||||
series = Series.objects.create(name="Test Series")
|
||||
episode = Episode.objects.create(
|
||||
series=series,
|
||||
name="Pilot",
|
||||
season_number=1,
|
||||
episode_number=1,
|
||||
)
|
||||
relation = M3UEpisodeRelation.objects.create(
|
||||
m3u_account=self.account,
|
||||
episode=episode,
|
||||
stream_id="888",
|
||||
container_extension="mp4",
|
||||
)
|
||||
|
||||
url = relation.get_stream_url()
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://myserver.fun/server1/series/alice/secret/888.mp4",
|
||||
)
|
||||
1727
apps/output/epg.py
Normal file
1727
apps/output/epg.py
Normal file
File diff suppressed because it is too large
Load diff
239
apps/output/streaming_chunk_cache.py
Normal file
239
apps/output/streaming_chunk_cache.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""Single-flight Redis chunk cache for large streaming HTTP responses."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATUS_BUILDING = "building"
|
||||
STATUS_READY = "ready"
|
||||
STATUS_ERROR = "error"
|
||||
|
||||
DEFAULT_CACHE_TTL = 300
|
||||
DEFAULT_LOCK_TTL = 120
|
||||
DEFAULT_POLL_INTERVAL = 0.05
|
||||
DEFAULT_MAX_FOLLOWER_WAIT = 600
|
||||
|
||||
|
||||
def _chunks_key(base_key):
|
||||
return f"{base_key}:chunks"
|
||||
|
||||
|
||||
def _ready_key(base_key):
|
||||
return f"{base_key}:ready"
|
||||
|
||||
|
||||
def _status_key(base_key):
|
||||
return f"{base_key}:status"
|
||||
|
||||
|
||||
def _lock_key(base_key):
|
||||
return f"{base_key}:lock"
|
||||
|
||||
|
||||
def _decode_chunk(chunk):
|
||||
if chunk is None:
|
||||
return None
|
||||
if isinstance(chunk, bytes):
|
||||
return chunk.decode("utf-8")
|
||||
return chunk
|
||||
|
||||
|
||||
def _encode_chunk(chunk):
|
||||
if isinstance(chunk, bytes):
|
||||
return chunk
|
||||
return chunk.encode("utf-8")
|
||||
|
||||
|
||||
def _poll_wait(interval):
|
||||
try:
|
||||
from core.utils import _is_gevent_monkey_patched
|
||||
|
||||
if _is_gevent_monkey_patched():
|
||||
import gevent
|
||||
|
||||
gevent.sleep(interval)
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def _get_redis():
|
||||
from django_redis import get_redis_connection
|
||||
|
||||
return get_redis_connection("default")
|
||||
|
||||
|
||||
def _get_status(redis, base_key):
|
||||
raw = redis.get(_status_key(base_key))
|
||||
if raw is None:
|
||||
return None
|
||||
return _decode_chunk(raw)
|
||||
|
||||
|
||||
def _clear_build_keys(redis, base_key):
|
||||
redis.delete(
|
||||
_chunks_key(base_key),
|
||||
_status_key(base_key),
|
||||
_ready_key(base_key),
|
||||
_lock_key(base_key),
|
||||
)
|
||||
|
||||
|
||||
def _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
return bool(redis.set(_lock_key(base_key), "1", nx=True, ex=lock_ttl))
|
||||
|
||||
|
||||
def _refresh_build_ttl(redis, base_key, lock_ttl):
|
||||
redis.expire(_lock_key(base_key), lock_ttl)
|
||||
redis.expire(_status_key(base_key), lock_ttl)
|
||||
redis.expire(_chunks_key(base_key), lock_ttl)
|
||||
|
||||
|
||||
def _stream_ready(redis, base_key):
|
||||
offset = 0
|
||||
chunks_key = _chunks_key(base_key)
|
||||
while True:
|
||||
chunk = redis.lindex(chunks_key, offset)
|
||||
if chunk is None:
|
||||
break
|
||||
yield _decode_chunk(chunk)
|
||||
offset += 1
|
||||
|
||||
|
||||
def _stream_build(redis, base_key, source, cache_ttl, lock_ttl):
|
||||
"""Leader: stream to client and append each chunk to Redis."""
|
||||
chunks_key = _chunks_key(base_key)
|
||||
status_key = _status_key(base_key)
|
||||
try:
|
||||
from django.core.cache import cache as django_cache
|
||||
|
||||
django_cache.delete(base_key) # clear any non-chunked entry under this key
|
||||
redis.delete(chunks_key, _ready_key(base_key))
|
||||
redis.set(status_key, STATUS_BUILDING, ex=lock_ttl)
|
||||
refresh_interval = max(1, lock_ttl // 4)
|
||||
last_refresh = 0.0
|
||||
for chunk in source():
|
||||
redis.rpush(chunks_key, _encode_chunk(chunk))
|
||||
now = time.monotonic()
|
||||
if now - last_refresh >= refresh_interval:
|
||||
_refresh_build_ttl(redis, base_key, lock_ttl)
|
||||
last_refresh = now
|
||||
yield chunk
|
||||
redis.set(status_key, STATUS_READY)
|
||||
redis.set(_ready_key(base_key), "1")
|
||||
redis.expire(chunks_key, cache_ttl)
|
||||
redis.expire(status_key, cache_ttl)
|
||||
redis.expire(_ready_key(base_key), cache_ttl)
|
||||
logger.debug("Cached response in %s chunks", redis.llen(chunks_key))
|
||||
except Exception:
|
||||
logger.exception("Chunk cache build failed for %s", base_key)
|
||||
redis.delete(chunks_key)
|
||||
redis.set(status_key, STATUS_ERROR, ex=60)
|
||||
raise
|
||||
finally:
|
||||
redis.delete(_lock_key(base_key))
|
||||
|
||||
|
||||
def _stream_follow(redis, base_key, source, cache_ttl, lock_ttl, poll_interval, max_follower_wait):
|
||||
"""Follower: read chunks as the leader writes them."""
|
||||
offset = 0
|
||||
deadline = time.monotonic() + max_follower_wait
|
||||
idle_polls = 0
|
||||
chunks_key = _chunks_key(base_key)
|
||||
lock_key = _lock_key(base_key)
|
||||
|
||||
while True:
|
||||
chunk = redis.lindex(chunks_key, offset)
|
||||
if chunk is not None:
|
||||
idle_polls = 0
|
||||
yield _decode_chunk(chunk)
|
||||
offset += 1
|
||||
continue
|
||||
|
||||
status = _get_status(redis, base_key)
|
||||
if status == STATUS_READY:
|
||||
break
|
||||
|
||||
if status == STATUS_ERROR:
|
||||
_clear_build_keys(redis, base_key)
|
||||
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||
return
|
||||
raise RuntimeError("Chunk cache build failed")
|
||||
|
||||
if time.monotonic() >= deadline:
|
||||
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
logger.warning("Chunk cache follower timed out; rebuilding %s", base_key)
|
||||
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||
return
|
||||
logger.warning("Chunk cache follower timed out after partial read for %s", base_key)
|
||||
break
|
||||
|
||||
lock_active = bool(redis.exists(lock_key))
|
||||
if status != STATUS_BUILDING and not lock_active:
|
||||
idle_polls += 1
|
||||
if offset == 0 and idle_polls >= max(1, int(1.0 / poll_interval)):
|
||||
if _try_acquire_lock(redis, base_key, lock_ttl):
|
||||
logger.warning("Chunk cache leader lost; rebuilding %s", base_key)
|
||||
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
|
||||
return
|
||||
else:
|
||||
idle_polls = 0
|
||||
|
||||
_poll_wait(poll_interval)
|
||||
|
||||
|
||||
def stream_cached_response(
|
||||
cache_key,
|
||||
source,
|
||||
*,
|
||||
content_type="application/xml",
|
||||
filename=None,
|
||||
cache_ttl=DEFAULT_CACHE_TTL,
|
||||
lock_ttl=DEFAULT_LOCK_TTL,
|
||||
poll_interval=DEFAULT_POLL_INTERVAL,
|
||||
max_follower_wait=DEFAULT_MAX_FOLLOWER_WAIT,
|
||||
redis=None,
|
||||
):
|
||||
"""
|
||||
Stream a large response with single-flight Redis chunk caching.
|
||||
|
||||
``source`` must be a callable returning a chunk iterator. Only the leader
|
||||
invokes it; concurrent followers replay chunks already written to Redis, so
|
||||
the expensive ``source`` runs at most once per ``cache_key``.
|
||||
"""
|
||||
if redis is None:
|
||||
redis = _get_redis()
|
||||
|
||||
if redis.get(_ready_key(cache_key)):
|
||||
logger.debug("Serving response from chunk cache")
|
||||
stream = _stream_ready(redis, cache_key)
|
||||
else:
|
||||
status = _get_status(redis, cache_key)
|
||||
if status == STATUS_ERROR:
|
||||
_clear_build_keys(redis, cache_key)
|
||||
|
||||
if _try_acquire_lock(redis, cache_key, lock_ttl):
|
||||
logger.debug("Building response (cache leader)")
|
||||
stream = _stream_build(redis, cache_key, source, cache_ttl, lock_ttl)
|
||||
else:
|
||||
logger.debug("Following in-flight cache build")
|
||||
stream = _stream_follow(
|
||||
redis,
|
||||
cache_key,
|
||||
source,
|
||||
cache_ttl,
|
||||
lock_ttl,
|
||||
poll_interval,
|
||||
max_follower_wait,
|
||||
)
|
||||
|
||||
response = StreamingHttpResponse(stream, content_type=content_type)
|
||||
if filename:
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
187
apps/output/test_streaming_chunk_cache.py
Normal file
187
apps/output/test_streaming_chunk_cache.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import threading
|
||||
import time
|
||||
from unittest import TestCase
|
||||
|
||||
from apps.output.streaming_chunk_cache import (
|
||||
STATUS_BUILDING,
|
||||
STATUS_READY,
|
||||
_chunks_key,
|
||||
_lock_key,
|
||||
_ready_key,
|
||||
_status_key,
|
||||
stream_cached_response,
|
||||
)
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal Redis stand-in for chunk-cache unit tests."""
|
||||
|
||||
def __init__(self):
|
||||
self._strings = {}
|
||||
self._lists = {}
|
||||
self._expires_at = {}
|
||||
|
||||
def _purge_expired(self):
|
||||
now = time.monotonic()
|
||||
expired = [key for key, deadline in self._expires_at.items() if deadline <= now]
|
||||
for key in expired:
|
||||
self._strings.pop(key, None)
|
||||
self._lists.pop(key, None)
|
||||
self._expires_at.pop(key, None)
|
||||
|
||||
def get(self, key):
|
||||
self._purge_expired()
|
||||
return self._strings.get(key)
|
||||
|
||||
def set(self, key, value, nx=False, ex=None):
|
||||
self._purge_expired()
|
||||
if nx and key in self._strings:
|
||||
return None
|
||||
self._strings[key] = value
|
||||
if ex is not None:
|
||||
self._expires_at[key] = time.monotonic() + ex
|
||||
return True
|
||||
|
||||
def delete(self, *keys):
|
||||
for key in keys:
|
||||
self._strings.pop(key, None)
|
||||
self._lists.pop(key, None)
|
||||
self._expires_at.pop(key, None)
|
||||
|
||||
def exists(self, key):
|
||||
self._purge_expired()
|
||||
return key in self._strings or key in self._lists
|
||||
|
||||
def expire(self, key, ttl):
|
||||
if key in self._strings or key in self._lists:
|
||||
self._expires_at[key] = time.monotonic() + ttl
|
||||
return True
|
||||
|
||||
def rpush(self, key, value):
|
||||
self._lists.setdefault(key, []).append(value)
|
||||
|
||||
def lindex(self, key, offset):
|
||||
items = self._lists.get(key, [])
|
||||
if offset < len(items):
|
||||
return items[offset]
|
||||
return None
|
||||
|
||||
def llen(self, key):
|
||||
return len(self._lists.get(key, []))
|
||||
|
||||
|
||||
def _consume(response):
|
||||
return b"".join(response.streaming_content).decode("utf-8")
|
||||
|
||||
|
||||
class StreamingChunkCacheTests(TestCase):
|
||||
def test_leader_caches_chunks_and_sets_ready(self):
|
||||
redis = FakeRedis()
|
||||
calls = []
|
||||
|
||||
def source():
|
||||
calls.append(1)
|
||||
yield "<tv>"
|
||||
yield "</tv>"
|
||||
|
||||
body = _consume(stream_cached_response("cache:test", source, redis=redis))
|
||||
|
||||
self.assertEqual(body, "<tv></tv>")
|
||||
self.assertEqual(calls, [1])
|
||||
self.assertEqual(redis.get(_ready_key("cache:test")), "1")
|
||||
self.assertEqual(redis.get(_status_key("cache:test")), STATUS_READY)
|
||||
self.assertEqual(redis.llen(_chunks_key("cache:test")), 2)
|
||||
self.assertFalse(redis.exists(_lock_key("cache:test")))
|
||||
|
||||
def test_cache_hit_skips_source(self):
|
||||
redis = FakeRedis()
|
||||
calls = []
|
||||
|
||||
def source():
|
||||
calls.append(1)
|
||||
yield "<tv>"
|
||||
yield "</tv>"
|
||||
|
||||
_consume(stream_cached_response("cache:test", source, redis=redis))
|
||||
calls.clear()
|
||||
body = _consume(stream_cached_response("cache:test", source, redis=redis))
|
||||
|
||||
self.assertEqual(body, "<tv></tv>")
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_follower_reads_leader_chunks_without_rebuilding(self):
|
||||
redis = FakeRedis()
|
||||
base = "cache:follow"
|
||||
leader_started = threading.Event()
|
||||
rebuild_calls = []
|
||||
|
||||
def slow_source():
|
||||
rebuild_calls.append(1)
|
||||
leader_started.set()
|
||||
yield "a"
|
||||
time.sleep(0.05)
|
||||
yield "b"
|
||||
|
||||
def forbidden_source():
|
||||
rebuild_calls.append(2)
|
||||
yield "SHOULD_NOT_RUN"
|
||||
|
||||
def leader():
|
||||
_consume(
|
||||
stream_cached_response(
|
||||
base,
|
||||
slow_source,
|
||||
redis=redis,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
leader_thread = threading.Thread(target=leader)
|
||||
leader_thread.start()
|
||||
leader_started.wait(timeout=5)
|
||||
follower_body = _consume(
|
||||
stream_cached_response(
|
||||
base,
|
||||
forbidden_source,
|
||||
redis=redis,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
)
|
||||
leader_thread.join(timeout=5)
|
||||
|
||||
self.assertEqual(follower_body, "ab")
|
||||
self.assertEqual(rebuild_calls, [1])
|
||||
|
||||
def test_only_one_leader_when_two_clients_start_together(self):
|
||||
redis = FakeRedis()
|
||||
build_calls = []
|
||||
barrier = threading.Barrier(2)
|
||||
results = {}
|
||||
|
||||
def source():
|
||||
build_calls.append(threading.current_thread().name)
|
||||
yield "x"
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
results[threading.current_thread().name] = _consume(
|
||||
stream_cached_response(
|
||||
"cache:race",
|
||||
source,
|
||||
redis=redis,
|
||||
poll_interval=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=worker, name="t1"),
|
||||
threading.Thread(target=worker, name="t2"),
|
||||
]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=10)
|
||||
|
||||
self.assertEqual(results["t1"], "x")
|
||||
self.assertEqual(results["t2"], "x")
|
||||
self.assertEqual(len(build_calls), 1)
|
||||
|
|
@ -1,31 +1,131 @@
|
|||
from django.test import TestCase, Client
|
||||
from django.test import TestCase, Client, SimpleTestCase, RequestFactory
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel, ChannelGroup
|
||||
from unittest import skipUnless
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from apps.channels.models import Channel, ChannelGroup, ChannelProfile, ChannelProfileMembership
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
from apps.accounts.models import User
|
||||
from apps.m3u.models import M3UAccount
|
||||
from apps.output.views import xc_get_series, xc_get_vod_streams
|
||||
from apps.vod.models import (
|
||||
M3UMovieRelation,
|
||||
M3USeriesRelation,
|
||||
Movie,
|
||||
Series,
|
||||
VODCategory,
|
||||
VODLogo,
|
||||
)
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def _response_text(response):
|
||||
"""Read body from HttpResponse or StreamingHttpResponse."""
|
||||
if getattr(response, "streaming", False):
|
||||
return b"".join(response.streaming_content).decode()
|
||||
return response.content.decode()
|
||||
|
||||
|
||||
def _epg_response_without_redis(cache_key, source, **kwargs):
|
||||
"""Test helper: stream EPG directly without Redis chunk caching."""
|
||||
from django.http import StreamingHttpResponse
|
||||
|
||||
response = StreamingHttpResponse(source(), content_type="application/xml")
|
||||
response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
|
||||
response["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
class OutputEndpointTestMixin:
|
||||
"""Isolate HTTP endpoint tests from network ACL, logging, DB teardown, and Redis."""
|
||||
|
||||
class OutputM3UTest(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._network_patch = patch(
|
||||
"apps.output.views.network_access_allowed",
|
||||
return_value=True,
|
||||
)
|
||||
self._epg_teardown_patch = patch("apps.output.epg._epg_export_teardown")
|
||||
self._log_event_patch = patch("apps.output.views.log_system_event")
|
||||
self._epg_log_event_patch = patch("apps.output.epg.log_system_event")
|
||||
self._close_db_patch = patch("django.db.close_old_connections")
|
||||
self._epg_cache_patch = patch(
|
||||
"apps.output.epg.stream_cached_response",
|
||||
side_effect=_epg_response_without_redis,
|
||||
)
|
||||
self._network_patch.start()
|
||||
self._epg_teardown_patch.start()
|
||||
self._log_event_patch.start()
|
||||
self._epg_log_event_patch.start()
|
||||
self._close_db_patch.start()
|
||||
self._epg_cache_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.clear()
|
||||
self._epg_cache_patch.stop()
|
||||
self._close_db_patch.stop()
|
||||
self._epg_log_event_patch.stop()
|
||||
self._log_event_patch.stop()
|
||||
self._epg_teardown_patch.stop()
|
||||
self._network_patch.stop()
|
||||
super().tearDown()
|
||||
|
||||
def _create_isolated_profile(self, prefix):
|
||||
"""New profiles auto-include every channel via signal; clear that for tests."""
|
||||
profile = ChannelProfile.objects.create(name=f"{prefix}-{uuid4().hex[:8]}")
|
||||
ChannelProfileMembership.objects.filter(channel_profile=profile).delete()
|
||||
return profile
|
||||
|
||||
def _add_channel_to_profile(self, profile, group, **kwargs):
|
||||
channel = Channel.objects.create(channel_group=group, **kwargs)
|
||||
ChannelProfileMembership.objects.create(
|
||||
channel_profile=profile,
|
||||
channel=channel,
|
||||
enabled=True,
|
||||
)
|
||||
return channel
|
||||
|
||||
|
||||
class OutputM3UTest(OutputEndpointTestMixin, TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = Client()
|
||||
|
||||
self.group = ChannelGroup.objects.create(name=f"M3U Group {uuid4().hex[:8]}")
|
||||
self.profile = self._create_isolated_profile("m3u")
|
||||
self._add_channel_to_profile(
|
||||
self.profile,
|
||||
self.group,
|
||||
channel_number=1.0,
|
||||
name="Test M3U Channel",
|
||||
)
|
||||
|
||||
def _m3u_url(self):
|
||||
return reverse("output:m3u_endpoint", kwargs={"profile_name": self.profile.name})
|
||||
|
||||
def test_generate_m3u_response(self):
|
||||
"""
|
||||
Test that the M3U endpoint returns a valid M3U file.
|
||||
"""
|
||||
url = reverse('output:generate_m3u')
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._m3u_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
self.assertIn("#EXTM3U", content)
|
||||
|
||||
def test_generate_m3u_response_post_empty_body(self):
|
||||
"""
|
||||
Test that a POST request with an empty body returns 200 OK.
|
||||
"""
|
||||
url = reverse('output:generate_m3u')
|
||||
|
||||
response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded')
|
||||
content = response.content.decode()
|
||||
response = self.client.post(
|
||||
self._m3u_url(),
|
||||
data=None,
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
content = _response_text(response)
|
||||
|
||||
self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK")
|
||||
self.assertIn("#EXTM3U", content)
|
||||
|
|
@ -34,35 +134,40 @@ class OutputM3UTest(TestCase):
|
|||
"""
|
||||
Test that a POST request with a non-empty body returns 403 Forbidden.
|
||||
"""
|
||||
url = reverse('output:generate_m3u')
|
||||
|
||||
response = self.client.post(url, data={'evilstring': 'muhahaha'})
|
||||
response = self.client.post(self._m3u_url(), data={"evilstring": "muhahaha"})
|
||||
|
||||
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
|
||||
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
|
||||
self.assertIn("POST requests with body are not allowed", _response_text(response))
|
||||
|
||||
|
||||
class OutputEPGXMLEscapingTest(TestCase):
|
||||
class OutputEPGXMLEscapingTest(OutputEndpointTestMixin, TestCase):
|
||||
"""Test XML escaping of channel_id attributes in EPG generation"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.client = Client()
|
||||
self.group = ChannelGroup.objects.create(name="Test Group")
|
||||
self.group = ChannelGroup.objects.create(name=f"Test Group {uuid4().hex[:8]}")
|
||||
self.profile = self._create_isolated_profile("epg-xml")
|
||||
|
||||
def _add_channel(self, **kwargs):
|
||||
return self._add_channel_to_profile(self.profile, self.group, **kwargs)
|
||||
|
||||
def _epg_url(self, query="tvg_id_source=tvg_id&days=0&prev_days=0"):
|
||||
base = reverse("output:epg_endpoint", kwargs={"profile_name": self.profile.name})
|
||||
return f"{base}?{query}"
|
||||
|
||||
def test_channel_id_with_ampersand(self):
|
||||
"""Test channel ID with ampersand is properly escaped"""
|
||||
channel = Channel.objects.create(
|
||||
self._add_channel(
|
||||
channel_number=1.0,
|
||||
name="Test Channel",
|
||||
tvg_id="News & Sports",
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
|
||||
# Should contain escaped ampersand
|
||||
self.assertIn('id="News & Sports"', content)
|
||||
|
|
@ -76,17 +181,15 @@ class OutputEPGXMLEscapingTest(TestCase):
|
|||
|
||||
def test_channel_id_with_angle_brackets(self):
|
||||
"""Test channel ID with < and > characters"""
|
||||
channel = Channel.objects.create(
|
||||
self._add_channel(
|
||||
channel_number=2.0,
|
||||
name="HD Channel",
|
||||
tvg_id="Channel <HD>",
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
self.assertIn('id="Channel <HD>"', content)
|
||||
|
||||
try:
|
||||
|
|
@ -96,23 +199,28 @@ class OutputEPGXMLEscapingTest(TestCase):
|
|||
|
||||
def test_channel_id_with_all_special_chars(self):
|
||||
"""Test channel ID with all XML special characters"""
|
||||
channel = Channel.objects.create(
|
||||
expected_id = 'Test & "Special" <Chars>'
|
||||
self._add_channel(
|
||||
channel_number=3.0,
|
||||
name="Complex Channel",
|
||||
tvg_id='Test & "Special" <Chars>',
|
||||
channel_group=self.group
|
||||
tvg_id=expected_id,
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
self.assertIn('id="Test & "Special" <Chars>"', content)
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(content)
|
||||
# Verify we can find the channel with correct ID in parsed tree
|
||||
channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" <Chars>"]')
|
||||
channel_elem = next(
|
||||
(
|
||||
elem
|
||||
for elem in tree.findall(".//channel")
|
||||
if elem.get("id") == expected_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
self.assertIsNotNone(channel_elem)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
|
||||
|
|
@ -121,25 +229,670 @@ class OutputEPGXMLEscapingTest(TestCase):
|
|||
"""Test that programme elements also have escaped channel attributes"""
|
||||
epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
|
||||
epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
|
||||
channel = Channel.objects.create(
|
||||
self._add_channel(
|
||||
channel_number=4.0,
|
||||
name="Program Test",
|
||||
tvg_id="News & Sports",
|
||||
epg_data=epg_data,
|
||||
channel_group=self.group
|
||||
)
|
||||
|
||||
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
|
||||
response = self.client.get(url)
|
||||
response = self.client.get(self._epg_url())
|
||||
|
||||
content = response.content.decode()
|
||||
content = _response_text(response)
|
||||
|
||||
# Check programme elements have escaped channel attributes
|
||||
self.assertIn('channel="News & Sports"', content)
|
||||
|
||||
try:
|
||||
tree = ET.fromstring(content)
|
||||
programmes = tree.findall('.//programme[@channel="News & Sports"]')
|
||||
programmes = [
|
||||
programme
|
||||
for programme in tree.findall(".//programme")
|
||||
if programme.get("channel") == "News & Sports"
|
||||
]
|
||||
self.assertGreater(len(programmes), 0)
|
||||
except ET.ParseError as e:
|
||||
self.fail(f"Generated EPG with programme elements is not valid XML: {e}")
|
||||
|
||||
def test_programmes_emitted_in_start_time_order(self):
|
||||
"""Programmes for a channel are emitted in start_time order, not insert order."""
|
||||
from django.utils import timezone
|
||||
from apps.epg.models import ProgramData
|
||||
|
||||
epg_source = EPGSource.objects.create(name="Real EPG", source_type="xmltv")
|
||||
epg_data = EPGData.objects.create(name="Station", epg_source=epg_source, tvg_id="station1")
|
||||
self._add_channel(
|
||||
channel_number=149.0,
|
||||
name="Food Network",
|
||||
tvg_id="station1",
|
||||
epg_data=epg_data,
|
||||
)
|
||||
now = timezone.now()
|
||||
# Insert out of chronological order so id order != start_time order.
|
||||
ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
start_time=now + timedelta(days=3),
|
||||
end_time=now + timedelta(days=3, hours=1),
|
||||
title="Third",
|
||||
tvg_id="station1",
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
start_time=now + timedelta(days=1),
|
||||
end_time=now + timedelta(days=1, hours=1),
|
||||
title="First",
|
||||
tvg_id="station1",
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=epg_data,
|
||||
start_time=now + timedelta(days=2),
|
||||
end_time=now + timedelta(days=2, hours=1),
|
||||
title="Second",
|
||||
tvg_id="station1",
|
||||
)
|
||||
|
||||
content = _response_text(self.client.get(self._epg_url("tvg_id_source=tvg_id&days=7")))
|
||||
|
||||
self.assertLess(content.find('<title>First</title>'), content.find('<title>Second</title>'))
|
||||
self.assertLess(content.find('<title>Second</title>'), content.find('<title>Third</title>'))
|
||||
|
||||
|
||||
class OutputEPGCustomDummyTest(TestCase):
|
||||
"""Custom dummy EPG must not fall back to default when pattern matched but event is outside window."""
|
||||
|
||||
def setUp(self):
|
||||
self.group = ChannelGroup.objects.create(name="Sports Group")
|
||||
|
||||
def test_custom_dummy_outside_window_fills_with_ended_programmes(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.views import generate_dummy_programs
|
||||
|
||||
epg_source = EPGSource.objects.create(
|
||||
name="NHL Dummy",
|
||||
source_type="dummy",
|
||||
custom_properties={
|
||||
"title_pattern": r"(?<league>.*)\s\d+:\s(?<team1>.*?)(?:\s+vs\s+)(?<team2>.*?)\s*@.*",
|
||||
"time_pattern": r"(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<ampm>AM|PM)",
|
||||
"date_pattern": r"@ (?<month>[A-Za-z]+)\s+(?<day>\d{1,2})",
|
||||
"timezone": "US/Eastern",
|
||||
"program_duration": 180,
|
||||
},
|
||||
)
|
||||
channel_name = (
|
||||
"NHL 01: Washington Capitals vs Philadelphia Flyers @ April 16 07:30 PM ET"
|
||||
)
|
||||
now = timezone.now()
|
||||
lookback = now - timedelta(days=7)
|
||||
|
||||
programs = generate_dummy_programs(
|
||||
channel_id="nhl01",
|
||||
channel_name=channel_name,
|
||||
num_days=7,
|
||||
epg_source=epg_source,
|
||||
export_lookback=lookback,
|
||||
export_cutoff=now + timedelta(days=7),
|
||||
)
|
||||
|
||||
self.assertGreater(len(programs), 0)
|
||||
self.assertTrue(
|
||||
all(p['end_time'] >= lookback for p in programs),
|
||||
"All programmes should fall inside the export window",
|
||||
)
|
||||
self.assertTrue(
|
||||
any('Ended' in p['description'] for p in programs),
|
||||
"Past events outside the window should still show ended filler",
|
||||
)
|
||||
for program in programs:
|
||||
start = program['start_time']
|
||||
self.assertEqual(start.second, 0)
|
||||
self.assertEqual(start.microsecond, 0)
|
||||
self.assertIn(
|
||||
start.minute, (0, 30),
|
||||
"Filler programmes should start on half-hour boundaries",
|
||||
)
|
||||
self.assertGreaterEqual(programs[0]['start_time'], lookback)
|
||||
|
||||
def test_custom_dummy_future_event_fills_grid_window_with_upcoming(self):
|
||||
"""Grid-style window: future event should show upcoming filler, not empty."""
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _programme_overlaps_export_window, generate_dummy_programs
|
||||
|
||||
epg_source = EPGSource.objects.create(
|
||||
name="NHL Dummy Future",
|
||||
source_type="dummy",
|
||||
custom_properties={
|
||||
"title_pattern": r"(?<league>.*)\s\d+:\s(?<team1>.*?)(?:\s+vs\s+)(?<team2>.*?)\s*@.*",
|
||||
"time_pattern": r"(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<ampm>AM|PM)",
|
||||
"date_pattern": r"@ (?<month>[A-Za-z]+)\s+(?<day>\d{1,2})",
|
||||
"timezone": "US/Eastern",
|
||||
"program_duration": 180,
|
||||
},
|
||||
)
|
||||
now = timezone.now()
|
||||
grid_start = now - timedelta(hours=1)
|
||||
grid_end = now + timedelta(hours=24)
|
||||
future = now + timedelta(days=3)
|
||||
channel_name = (
|
||||
f"NHL 01: Washington Capitals vs Philadelphia Flyers @ "
|
||||
f"{future.strftime('%B')} {future.day} 07:30 PM ET"
|
||||
)
|
||||
|
||||
programs = generate_dummy_programs(
|
||||
channel_id="nhl01",
|
||||
channel_name=channel_name,
|
||||
num_days=1,
|
||||
epg_source=epg_source,
|
||||
export_lookback=grid_start,
|
||||
export_cutoff=grid_end,
|
||||
)
|
||||
|
||||
self.assertGreater(len(programs), 0)
|
||||
self.assertTrue(
|
||||
all(
|
||||
_programme_overlaps_export_window(
|
||||
p["start_time"], p["end_time"], grid_start, grid_end
|
||||
)
|
||||
for p in programs
|
||||
),
|
||||
"All programmes should overlap the grid query window",
|
||||
)
|
||||
self.assertTrue(
|
||||
any("Upcoming" in p.get("description", "") for p in programs),
|
||||
"Future events outside the window should show upcoming filler",
|
||||
)
|
||||
|
||||
|
||||
class OutputEPGHelperTest(SimpleTestCase):
|
||||
def test_ceil_to_half_hour_on_boundary(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _ceil_to_half_hour
|
||||
|
||||
dt = timezone.now().replace(minute=30, second=0, microsecond=0)
|
||||
self.assertEqual(_ceil_to_half_hour(dt), dt)
|
||||
|
||||
def test_ceil_to_half_hour_rounds_up(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _ceil_to_half_hour
|
||||
|
||||
dt = timezone.now().replace(minute=17, second=42, microsecond=123456)
|
||||
aligned = _ceil_to_half_hour(dt)
|
||||
self.assertEqual(aligned.minute, 30)
|
||||
self.assertEqual(aligned.second, 0)
|
||||
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
|
||||
|
||||
def test_ceil_to_half_hour_past_boundary_second(self):
|
||||
from django.utils import timezone
|
||||
from apps.output.epg import _ceil_to_half_hour
|
||||
|
||||
dt = timezone.now().replace(minute=0, second=52, microsecond=123456)
|
||||
aligned = _ceil_to_half_hour(dt)
|
||||
self.assertEqual(aligned.minute, 30)
|
||||
self.assertEqual(aligned.second, 0)
|
||||
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
|
||||
|
||||
|
||||
class XcVodSeriesDistinctTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username=f"xc-{uuid4().hex[:8]}",
|
||||
password="pass",
|
||||
custom_properties={"xc_password": "xcpass"},
|
||||
)
|
||||
self.request = self.factory.get("/player_api.php")
|
||||
|
||||
def _account(self, name, *, priority=0, is_active=True):
|
||||
return M3UAccount.objects.create(
|
||||
name=name,
|
||||
server_url="http://example.com",
|
||||
priority=priority,
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
def test_vod_streams_picks_highest_priority_relation(self):
|
||||
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
|
||||
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
|
||||
movie = Movie.objects.create(name="Shared Movie", year=2020)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=low,
|
||||
movie=movie,
|
||||
stream_id="low-stream",
|
||||
container_extension="mkv",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=high,
|
||||
movie=movie,
|
||||
stream_id="high-stream",
|
||||
container_extension="mp4",
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["name"], "Shared Movie")
|
||||
self.assertEqual(streams[0]["container_extension"], "mp4")
|
||||
|
||||
def test_vod_streams_excludes_inactive_accounts(self):
|
||||
active = self._account(f"active-{uuid4().hex[:6]}", priority=1)
|
||||
inactive = self._account(
|
||||
f"inactive-{uuid4().hex[:6]}", priority=99, is_active=False
|
||||
)
|
||||
active_movie = Movie.objects.create(name="Active Movie")
|
||||
inactive_movie = Movie.objects.create(name="Inactive Only Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=active,
|
||||
movie=active_movie,
|
||||
stream_id="active-1",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=inactive,
|
||||
movie=inactive_movie,
|
||||
stream_id="inactive-1",
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
names = {s["name"] for s in streams}
|
||||
self.assertEqual(names, {"Active Movie"})
|
||||
|
||||
def test_vod_streams_category_filter(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
action = VODCategory.objects.create(name="Action", category_type="movie")
|
||||
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
|
||||
action_movie = Movie.objects.create(name="Action Movie")
|
||||
comedy_movie = Movie.objects.create(name="Comedy Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=action_movie,
|
||||
category=action,
|
||||
stream_id="action-1",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=comedy_movie,
|
||||
category=comedy,
|
||||
stream_id="comedy-1",
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user, category_id=action.id)
|
||||
|
||||
self.assertEqual(len(streams), 1)
|
||||
self.assertEqual(streams[0]["name"], "Action Movie")
|
||||
self.assertEqual(streams[0]["category_id"], str(action.id))
|
||||
|
||||
def test_vod_streams_sorted_alphabetically_by_name(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
zebra = Movie.objects.create(name="Zebra Film")
|
||||
apple = Movie.objects.create(name="Apple Film")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=zebra, stream_id="z-1"
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=apple, stream_id="a-1"
|
||||
)
|
||||
|
||||
streams = xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
self.assertEqual([s["name"] for s in streams], ["Apple Film", "Zebra Film"])
|
||||
|
||||
def test_vod_streams_includes_metadata_fields(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(
|
||||
name="Rich Movie",
|
||||
description="A plot",
|
||||
genre="Drama",
|
||||
year=2021,
|
||||
rating="8",
|
||||
custom_properties={
|
||||
"director": "Dir",
|
||||
"actors": "Cast",
|
||||
"release_date": "2021-01-01",
|
||||
"youtube_trailer": "yt123",
|
||||
},
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
stream_id="rich-1",
|
||||
container_extension="avi",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(stream["plot"], "A plot")
|
||||
self.assertEqual(stream["genre"], "Drama")
|
||||
self.assertEqual(stream["year"], 2021)
|
||||
self.assertEqual(stream["director"], "Dir")
|
||||
self.assertEqual(stream["cast"], "Cast")
|
||||
self.assertEqual(stream["release_date"], "2021-01-01")
|
||||
self.assertEqual(stream["trailer"], "yt123")
|
||||
self.assertEqual(stream["container_extension"], "avi")
|
||||
|
||||
def test_vod_streams_stream_icon_uses_logo_id_without_logo_join(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
logo = VODLogo.objects.create(name="Poster", url="http://example.com/poster.png")
|
||||
movie = Movie.objects.create(name="Logo Movie", logo=logo)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
stream_id="logo-1",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertIn(f"/{logo.id}/", stream["stream_icon"])
|
||||
|
||||
def test_series_picks_highest_priority_relation(self):
|
||||
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
|
||||
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
|
||||
series = Series.objects.create(name="Shared Series", year=2019)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=low,
|
||||
series=series,
|
||||
external_series_id="low-series",
|
||||
)
|
||||
high_rel = M3USeriesRelation.objects.create(
|
||||
m3u_account=high,
|
||||
series=series,
|
||||
external_series_id="high-series",
|
||||
)
|
||||
|
||||
results = xc_get_series(self.request, self.user)
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["name"], "Shared Series")
|
||||
self.assertEqual(results[0]["series_id"], high_rel.id)
|
||||
|
||||
def test_series_excludes_inactive_accounts(self):
|
||||
active = self._account(f"active-{uuid4().hex[:6]}")
|
||||
inactive = self._account(f"inactive-{uuid4().hex[:6]}", is_active=False)
|
||||
active_series = Series.objects.create(name="Active Series")
|
||||
inactive_series = Series.objects.create(name="Inactive Only Series")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=active,
|
||||
series=active_series,
|
||||
external_series_id="active-s",
|
||||
)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=inactive,
|
||||
series=inactive_series,
|
||||
external_series_id="inactive-s",
|
||||
)
|
||||
|
||||
results = xc_get_series(self.request, self.user)
|
||||
|
||||
self.assertEqual({r["name"] for r in results}, {"Active Series"})
|
||||
|
||||
def test_series_sorted_alphabetically_by_name(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
z = Series.objects.create(name="Zulu Show")
|
||||
a = Series.objects.create(name="Alpha Show")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account, series=z, external_series_id="z"
|
||||
)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account, series=a, external_series_id="a"
|
||||
)
|
||||
|
||||
results = xc_get_series(self.request, self.user)
|
||||
|
||||
self.assertEqual([r["name"] for r in results], ["Alpha Show", "Zulu Show"])
|
||||
|
||||
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
|
||||
def test_vod_streams_dedupe_query_avoids_movie_join(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(name="Query Shape Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=movie, stream_id="qs-1"
|
||||
)
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
xc_get_vod_streams(self.request, self.user)
|
||||
|
||||
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
|
||||
self.assertEqual(len(distinct_queries), 1)
|
||||
self.assertNotIn('"vod_movie"', distinct_queries[0]["sql"])
|
||||
self.assertNotIn('"vod_vodlogo"', distinct_queries[0]["sql"])
|
||||
|
||||
fetch_queries = [
|
||||
q
|
||||
for q in ctx.captured_queries
|
||||
if '"vod_movie"' in q["sql"] and "DISTINCT" not in q["sql"]
|
||||
]
|
||||
self.assertGreaterEqual(len(fetch_queries), 1)
|
||||
fetch_sql = fetch_queries[0]["sql"]
|
||||
self.assertNotIn('"vod_vodlogo"', fetch_sql)
|
||||
self.assertNotIn('"vod_vodcategory"', fetch_sql)
|
||||
|
||||
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
|
||||
def test_series_dedupe_query_avoids_series_join(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
series = Series.objects.create(name="Query Shape Series")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account, series=series, external_series_id="qs-s"
|
||||
)
|
||||
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
xc_get_series(self.request, self.user)
|
||||
|
||||
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
|
||||
self.assertEqual(len(distinct_queries), 1)
|
||||
self.assertNotIn('"vod_series"', distinct_queries[0]["sql"])
|
||||
|
||||
fetch_queries = [
|
||||
q
|
||||
for q in ctx.captured_queries
|
||||
if '"vod_series"' in q["sql"] and "DISTINCT" not in q["sql"]
|
||||
]
|
||||
self.assertGreaterEqual(len(fetch_queries), 1)
|
||||
fetch_sql = fetch_queries[0]["sql"]
|
||||
self.assertNotIn('"vod_vodlogo"', fetch_sql)
|
||||
self.assertNotIn('"vod_vodcategory"', fetch_sql)
|
||||
|
||||
|
||||
XC_VOD_STREAM_KEYS = frozenset({
|
||||
"num", "name", "stream_type", "stream_id", "stream_icon", "rating",
|
||||
"rating_5based", "added", "is_adult", "tmdb_id", "imdb_id", "trailer",
|
||||
"plot", "genre", "year", "director", "cast", "release_date", "category_id",
|
||||
"category_ids", "container_extension", "custom_sid", "direct_source",
|
||||
})
|
||||
|
||||
XC_SERIES_KEYS = frozenset({
|
||||
"num", "name", "series_id", "cover", "plot", "cast", "director", "genre",
|
||||
"release_date", "releaseDate", "last_modified", "rating", "rating_5based",
|
||||
"backdrop_path", "youtube_trailer", "episode_run_time", "category_id",
|
||||
"category_ids", "tmdb_id", "imdb_id",
|
||||
})
|
||||
|
||||
|
||||
class XcVodSeriesRegressionTests(TestCase):
|
||||
"""Full output-shape and edge-case regressions for XC list endpoints."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.user = User.objects.create_user(
|
||||
username=f"xc-reg-{uuid4().hex[:8]}",
|
||||
password="pass",
|
||||
custom_properties={"xc_password": "xcpass"},
|
||||
)
|
||||
self.request = self.factory.get("/player_api.php")
|
||||
|
||||
def _account(self, name, *, priority=0):
|
||||
return M3UAccount.objects.create(
|
||||
name=name,
|
||||
server_url="http://example.com",
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
def test_vod_streams_empty_library(self):
|
||||
self.assertEqual(xc_get_vod_streams(self.request, self.user), [])
|
||||
|
||||
def test_series_empty_library(self):
|
||||
self.assertEqual(xc_get_series(self.request, self.user), [])
|
||||
|
||||
def test_vod_streams_response_keys(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(name="Schema Movie", rating="10")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account, movie=movie, stream_id="schema-1"
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(set(stream.keys()), XC_VOD_STREAM_KEYS)
|
||||
self.assertEqual(stream["stream_type"], "movie")
|
||||
self.assertEqual(stream["stream_id"], movie.id)
|
||||
self.assertEqual(stream["rating_5based"], 5.0)
|
||||
self.assertEqual(stream["custom_sid"], None)
|
||||
self.assertEqual(stream["direct_source"], "")
|
||||
|
||||
def test_vod_streams_null_optional_fields(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
movie = Movie.objects.create(name="Sparse Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=account,
|
||||
movie=movie,
|
||||
stream_id="sparse-1",
|
||||
container_extension=None,
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertIsNone(stream["stream_icon"])
|
||||
self.assertEqual(stream["category_id"], "0")
|
||||
self.assertEqual(stream["category_ids"], [])
|
||||
self.assertEqual(stream["container_extension"], "mp4")
|
||||
self.assertEqual(stream["plot"], "")
|
||||
self.assertEqual(stream["trailer"], "")
|
||||
self.assertEqual(stream["tmdb_id"], "")
|
||||
self.assertEqual(stream["imdb_id"], "")
|
||||
|
||||
def test_vod_streams_category_from_winning_relation(self):
|
||||
"""Category must come from the highest-priority relation, not any relation."""
|
||||
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
|
||||
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
|
||||
action = VODCategory.objects.create(name="Action", category_type="movie")
|
||||
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
|
||||
movie = Movie.objects.create(name="Dual Category Movie")
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=low,
|
||||
movie=movie,
|
||||
category=action,
|
||||
stream_id="low-cat",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=high,
|
||||
movie=movie,
|
||||
category=comedy,
|
||||
stream_id="high-cat",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(stream["category_id"], str(comedy.id))
|
||||
self.assertEqual(stream["category_ids"], [comedy.id])
|
||||
|
||||
def test_series_response_keys_and_metadata(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
logo = VODLogo.objects.create(name="Cover", url="http://example.com/cover.png")
|
||||
category = VODCategory.objects.create(name="Drama", category_type="series")
|
||||
series = Series.objects.create(
|
||||
name="Schema Series",
|
||||
description="Series plot",
|
||||
genre="Sci-Fi",
|
||||
year=2022,
|
||||
rating="8",
|
||||
tmdb_id="tm123",
|
||||
imdb_id="tt123",
|
||||
logo=logo,
|
||||
custom_properties={
|
||||
"cast": "Actor A",
|
||||
"director": "Director B",
|
||||
"release_date": "2022-06-01",
|
||||
"backdrop_path": ["/img1.jpg"],
|
||||
"youtube_trailer": "yt-series",
|
||||
"episode_run_time": "45",
|
||||
},
|
||||
)
|
||||
relation = M3USeriesRelation.objects.create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
category=category,
|
||||
external_series_id="schema-s",
|
||||
)
|
||||
|
||||
row = xc_get_series(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(set(row.keys()), XC_SERIES_KEYS)
|
||||
self.assertEqual(row["series_id"], relation.id)
|
||||
self.assertIn(f"/{logo.id}/", row["cover"])
|
||||
self.assertEqual(row["plot"], "Series plot")
|
||||
self.assertEqual(row["cast"], "Actor A")
|
||||
self.assertEqual(row["director"], "Director B")
|
||||
self.assertEqual(row["genre"], "Sci-Fi")
|
||||
self.assertEqual(row["release_date"], "2022-06-01")
|
||||
self.assertEqual(row["releaseDate"], "2022-06-01")
|
||||
self.assertEqual(row["backdrop_path"], ["/img1.jpg"])
|
||||
self.assertEqual(row["youtube_trailer"], "yt-series")
|
||||
self.assertEqual(row["episode_run_time"], "45")
|
||||
self.assertEqual(row["tmdb_id"], "tm123")
|
||||
self.assertEqual(row["imdb_id"], "tt123")
|
||||
self.assertEqual(row["category_id"], str(category.id))
|
||||
self.assertEqual(row["category_ids"], [category.id])
|
||||
self.assertEqual(row["last_modified"], str(int(relation.updated_at.timestamp())))
|
||||
|
||||
def test_series_null_optional_fields(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
series = Series.objects.create(name="Sparse Series")
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
external_series_id="sparse-s",
|
||||
)
|
||||
|
||||
row = xc_get_series(self.request, self.user)[0]
|
||||
|
||||
self.assertIsNone(row["cover"])
|
||||
self.assertEqual(row["category_id"], "0")
|
||||
self.assertEqual(row["category_ids"], [])
|
||||
self.assertEqual(row["release_date"], "")
|
||||
self.assertEqual(row["releaseDate"], "")
|
||||
self.assertEqual(row["backdrop_path"], [])
|
||||
self.assertEqual(row["youtube_trailer"], "")
|
||||
self.assertEqual(row["episode_run_time"], "")
|
||||
|
||||
def test_series_release_date_falls_back_to_year(self):
|
||||
account = self._account(f"acct-{uuid4().hex[:6]}")
|
||||
series = Series.objects.create(name="Year Only", year=2018)
|
||||
M3USeriesRelation.objects.create(
|
||||
m3u_account=account,
|
||||
series=series,
|
||||
external_series_id="year-s",
|
||||
)
|
||||
|
||||
row = xc_get_series(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(row["release_date"], "2018")
|
||||
self.assertEqual(row["releaseDate"], "2018")
|
||||
|
||||
def test_priority_tiebreaker_uses_lower_relation_id(self):
|
||||
"""Same priority: DISTINCT ON tie-breaks on relation id ascending."""
|
||||
a1 = self._account(f"a1-{uuid4().hex[:6]}", priority=5)
|
||||
a2 = self._account(f"a2-{uuid4().hex[:6]}", priority=5)
|
||||
movie = Movie.objects.create(name="Tie Movie")
|
||||
first = M3UMovieRelation.objects.create(
|
||||
m3u_account=a1,
|
||||
movie=movie,
|
||||
stream_id="first",
|
||||
container_extension="mkv",
|
||||
)
|
||||
M3UMovieRelation.objects.create(
|
||||
m3u_account=a2,
|
||||
movie=movie,
|
||||
stream_id="second",
|
||||
container_extension="mp4",
|
||||
)
|
||||
|
||||
stream = xc_get_vod_streams(self.request, self.user)[0]
|
||||
|
||||
self.assertEqual(stream["container_extension"], first.container_extension)
|
||||
|
|
|
|||
1825
apps/output/views.py
1825
apps/output/views.py
File diff suppressed because it is too large
Load diff
|
|
@ -133,7 +133,7 @@ class TSConfig(BaseConfig):
|
|||
|
||||
@classmethod
|
||||
def get_channel_init_grace_period(cls):
|
||||
"""Get channel init grace period from database or default"""
|
||||
"""Max seconds to wait for initial buffer fill during channel startup."""
|
||||
settings = cls.get_proxy_settings()
|
||||
return settings.get("channel_init_grace_period", 5)
|
||||
|
||||
|
|
|
|||
|
|
@ -301,6 +301,8 @@ class ClientManager:
|
|||
# Trigger channel stats update via WebSocket
|
||||
self._trigger_stats_update()
|
||||
|
||||
ChannelService.promote_channel_when_buffer_ready(self.channel_id)
|
||||
|
||||
# Get total clients across all workers
|
||||
total_clients = self.get_total_client_count()
|
||||
logger.info(f"New client connected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ class ConfigHelper:
|
|||
|
||||
@staticmethod
|
||||
def channel_init_grace_period():
|
||||
"""Get channel initialization grace period in seconds"""
|
||||
"""Max seconds to wait for initial buffer fill during channel startup."""
|
||||
return Config.get_channel_init_grace_period()
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -1420,6 +1420,12 @@ class StreamManager:
|
|||
"""Check if connection retry is allowed"""
|
||||
return self.retry_count < self.max_retries
|
||||
|
||||
def _health_inactivity_threshold(self):
|
||||
"""How long without data before marking the stream unhealthy."""
|
||||
if self.connected and getattr(self.buffer, 'index', 0) == 0:
|
||||
return ConfigHelper.channel_init_grace_period()
|
||||
return getattr(Config, 'CONNECTION_TIMEOUT', 10)
|
||||
|
||||
def _monitor_health(self):
|
||||
"""Monitor stream health and set flags for the main loop to handle recovery"""
|
||||
consecutive_unhealthy_checks = 0
|
||||
|
|
@ -1435,7 +1441,7 @@ class StreamManager:
|
|||
try:
|
||||
now = time.time()
|
||||
inactivity_duration = now - self.last_data_time
|
||||
timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10)
|
||||
timeout_threshold = self._health_inactivity_threshold()
|
||||
|
||||
if inactivity_duration > timeout_threshold and self.connected:
|
||||
if self.healthy:
|
||||
|
|
@ -1830,19 +1836,9 @@ class StreamManager:
|
|||
timer.start()
|
||||
return False
|
||||
|
||||
# We have enough buffer, proceed with state change
|
||||
update_data = {
|
||||
ChannelMetadataField.STATE: ChannelState.WAITING_FOR_CLIENTS,
|
||||
ChannelMetadataField.CONNECTION_READY_TIME: current_time,
|
||||
ChannelMetadataField.STATE_CHANGED_AT: current_time,
|
||||
ChannelMetadataField.BUFFER_CHUNKS: str(current_buffer_index)
|
||||
}
|
||||
redis_client.hset(metadata_key, mapping=update_data)
|
||||
from ..services.channel_service import ChannelService
|
||||
|
||||
# Get configured grace period or default
|
||||
grace_period = ConfigHelper.channel_init_grace_period()
|
||||
logger.info(f"STREAM MANAGER: Updated channel {channel_id} state: {current_state or 'None'} -> {ChannelState.WAITING_FOR_CLIENTS} with {current_buffer_index} buffer chunks")
|
||||
logger.info(f"Started initial connection grace period ({grace_period}s) for channel {channel_id}")
|
||||
ChannelService.promote_channel_when_buffer_ready(channel_id)
|
||||
else:
|
||||
logger.debug(f"Not changing state: channel {channel_id} already in {current_state} state")
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import time
|
|||
import gevent
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
from apps.channels.models import Channel, Stream
|
||||
from django.db import close_old_connections
|
||||
from core.utils import log_system_event
|
||||
from ...server import ProxyServer
|
||||
from ...utils import create_ts_packet, get_logger
|
||||
|
|
@ -158,8 +159,8 @@ class StreamGenerator:
|
|||
return "client_gone"
|
||||
|
||||
elapsed = time.time() - initialization_start
|
||||
connection_timeout = getattr(Config, 'CONNECTION_TIMEOUT', 10)
|
||||
if elapsed < connection_timeout or not proxy_server.redis_client:
|
||||
init_grace_period = ConfigHelper.channel_init_grace_period()
|
||||
if elapsed < init_grace_period or not proxy_server.redis_client:
|
||||
return None
|
||||
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
|
|
@ -203,7 +204,7 @@ class StreamGenerator:
|
|||
logger.warning(
|
||||
f"[{self.client_id}] Channel {self.channel_id} stalled in connecting state "
|
||||
f"with no buffer data after "
|
||||
f"{getattr(Config, 'CONNECTION_TIMEOUT', 10)}s, aborting init wait"
|
||||
f"{ConfigHelper.channel_init_grace_period()}s, aborting init wait"
|
||||
)
|
||||
yield create_ts_packet('error', "Error: Connection stalled")
|
||||
return False
|
||||
|
|
@ -590,60 +591,63 @@ class StreamGenerator:
|
|||
|
||||
def _cleanup(self):
|
||||
"""Clean up resources and report final statistics."""
|
||||
# Client cleanup
|
||||
elapsed = time.time() - self.stream_start_time
|
||||
local_clients = 0
|
||||
total_clients = 0
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
try:
|
||||
# Client cleanup
|
||||
elapsed = time.time() - self.stream_start_time
|
||||
local_clients = 0
|
||||
total_clients = 0
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
||||
# Release M3U profile stream allocation if this is the last client
|
||||
stream_released = False
|
||||
if proxy_server.redis_client:
|
||||
try:
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
|
||||
# Pool slots are global; the last client on any worker must release.
|
||||
# During shutdown_delay, keep the slot until coordinated stop runs.
|
||||
if client_count <= 1 and ConfigHelper.channel_shutdown_delay() <= 0:
|
||||
try:
|
||||
# Release M3U profile stream allocation if this is the last client
|
||||
stream_released = False
|
||||
if proxy_server.redis_client:
|
||||
try:
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
|
||||
# Pool slots are global; the last client on any worker must release.
|
||||
# During shutdown_delay, keep the slot until coordinated stop runs.
|
||||
if client_count <= 1 and ConfigHelper.channel_shutdown_delay() <= 0:
|
||||
try:
|
||||
obj = Channel.objects.get(uuid=self.channel_id)
|
||||
except (Channel.DoesNotExist, Exception):
|
||||
obj = Stream.objects.get(stream_hash=self.channel_id)
|
||||
stream_released = obj.release_stream()
|
||||
if stream_released:
|
||||
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
|
||||
else:
|
||||
logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Error checking stream data for release: {e}")
|
||||
try:
|
||||
obj = Channel.objects.get(uuid=self.channel_id)
|
||||
except (Channel.DoesNotExist, Exception):
|
||||
obj = Stream.objects.get(stream_hash=self.channel_id)
|
||||
stream_released = obj.release_stream()
|
||||
if stream_released:
|
||||
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
|
||||
else:
|
||||
logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.client_id}] Error checking stream data for release: {e}")
|
||||
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id in client_manager.clients:
|
||||
local_clients = client_manager.remove_client(self.client_id)
|
||||
else:
|
||||
local_clients = client_manager.get_client_count()
|
||||
total_clients = client_manager.get_total_client_count()
|
||||
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
|
||||
if self.channel_id in proxy_server.client_managers:
|
||||
client_manager = proxy_server.client_managers[self.channel_id]
|
||||
if self.client_id in client_manager.clients:
|
||||
local_clients = client_manager.remove_client(self.client_id)
|
||||
else:
|
||||
local_clients = client_manager.get_client_count()
|
||||
total_clients = client_manager.get_total_client_count()
|
||||
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
|
||||
|
||||
# Log client disconnect event
|
||||
try:
|
||||
log_system_event(
|
||||
'client_disconnect',
|
||||
channel_id=self.channel_id,
|
||||
channel_name=self.channel_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,
|
||||
duration=round(elapsed, 2),
|
||||
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}")
|
||||
# Log client disconnect event
|
||||
try:
|
||||
log_system_event(
|
||||
'client_disconnect',
|
||||
channel_id=self.channel_id,
|
||||
channel_name=self.channel_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,
|
||||
duration=round(elapsed, 2),
|
||||
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}")
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
|
||||
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None, buffer=None):
|
||||
|
|
|
|||
|
|
@ -789,7 +789,7 @@ class ProxyServer:
|
|||
attempt_key = RedisKeys.connection_attempt(channel_id)
|
||||
self.redis_client.setex(attempt_key, 60, str(time.time()))
|
||||
|
||||
logger.info(f"Channel {channel_id} in {ChannelState.CONNECTING} state - will start grace period after connection")
|
||||
logger.info(f"Channel {channel_id} in {ChannelState.CONNECTING} state - waiting for buffer to fill")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1758,7 +1758,7 @@ class ProxyServer:
|
|||
if time.time() % 30 < 1: # Every ~30 seconds
|
||||
logger.info(f"Channel {channel_id} has {total_clients} clients, state: {channel_state}")
|
||||
|
||||
# If in connecting or waiting_for_clients state, check grace period
|
||||
# Pre-active channels: init timeouts and buffer-ready promotion
|
||||
if channel_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
|
||||
# Check if channel is already stopping
|
||||
if self.redis_client:
|
||||
|
|
@ -1824,27 +1824,13 @@ class ProxyServer:
|
|||
)
|
||||
self._coordinated_stop_channel(channel_id)
|
||||
continue
|
||||
elif connection_ready_time:
|
||||
# We have clients now, but check grace period for state transition
|
||||
grace_period = ConfigHelper.channel_init_grace_period()
|
||||
time_since_ready = time.time() - connection_ready_time
|
||||
elif (
|
||||
channel_state == ChannelState.WAITING_FOR_CLIENTS
|
||||
and total_clients > 0
|
||||
):
|
||||
from .services.channel_service import ChannelService
|
||||
|
||||
logger.debug(f"GRACE PERIOD CHECK: Channel {channel_id} in {channel_state} state, "
|
||||
f"time_since_ready={time_since_ready:.1f}s, grace_period={grace_period}s, "
|
||||
f"total_clients={total_clients}")
|
||||
|
||||
if time_since_ready <= grace_period:
|
||||
# Still within grace period
|
||||
logger.debug(f"Channel {channel_id} in grace period - {time_since_ready:.1f}s of {grace_period}s elapsed")
|
||||
continue
|
||||
else:
|
||||
# Grace period expired with clients - mark channel as active
|
||||
logger.info(f"Grace period expired with {total_clients} clients - marking channel {channel_id} as active")
|
||||
if self.update_channel_state(channel_id, ChannelState.ACTIVE, {
|
||||
"grace_period_ended_at": str(time.time()),
|
||||
"clients_at_activation": str(total_clients)
|
||||
}):
|
||||
logger.info(f"Channel {channel_id} activated with {total_clients} clients after grace period")
|
||||
ChannelService.promote_channel_when_buffer_ready(channel_id)
|
||||
# If active and no clients, start normal shutdown procedure
|
||||
elif channel_state not in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS] and total_clients == 0:
|
||||
# Check if channel is already stopping
|
||||
|
|
|
|||
|
|
@ -192,6 +192,78 @@ class ChannelService:
|
|||
ChannelState.CONNECTING,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def promote_channel_when_buffer_ready(channel_id):
|
||||
"""
|
||||
Promote channel state once the initial buffer threshold is met.
|
||||
|
||||
- connecting/initializing + buffer ready + clients -> active
|
||||
- connecting/initializing + buffer ready + no clients -> waiting_for_clients
|
||||
- waiting_for_clients + clients -> active
|
||||
|
||||
Returns the resulting state, or None when no promotion applies.
|
||||
"""
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
redis_client = proxy_server.redis_client
|
||||
if not redis_client:
|
||||
return None
|
||||
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
state_raw = redis_client.hget(metadata_key, ChannelMetadataField.STATE)
|
||||
if not state_raw:
|
||||
return None
|
||||
|
||||
state = state_raw.decode() if isinstance(state_raw, bytes) else state_raw
|
||||
if state == ChannelState.ACTIVE:
|
||||
return ChannelState.ACTIVE
|
||||
|
||||
if state == ChannelState.WAITING_FOR_CLIENTS:
|
||||
ready_raw = redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.CONNECTION_READY_TIME
|
||||
)
|
||||
if not ready_raw:
|
||||
return None
|
||||
client_count = redis_client.scard(RedisKeys.clients(channel_id)) or 0
|
||||
if client_count <= 0:
|
||||
return ChannelState.WAITING_FOR_CLIENTS
|
||||
proxy_server.update_channel_state(
|
||||
channel_id,
|
||||
ChannelState.ACTIVE,
|
||||
{"clients_at_activation": str(client_count)},
|
||||
)
|
||||
return ChannelState.ACTIVE
|
||||
|
||||
if state not in (ChannelState.INITIALIZING, ChannelState.CONNECTING):
|
||||
return None
|
||||
|
||||
try:
|
||||
buffer_index = int(redis_client.get(RedisKeys.buffer_index(channel_id)) or 0)
|
||||
except (TypeError, ValueError):
|
||||
buffer_index = 0
|
||||
|
||||
chunks_needed = ConfigHelper.initial_behind_chunks()
|
||||
if buffer_index < chunks_needed:
|
||||
return None
|
||||
|
||||
client_count = redis_client.scard(RedisKeys.clients(channel_id)) or 0
|
||||
new_state = (
|
||||
ChannelState.ACTIVE if client_count > 0 else ChannelState.WAITING_FOR_CLIENTS
|
||||
)
|
||||
current_time = str(time.time())
|
||||
extra = {
|
||||
ChannelMetadataField.CONNECTION_READY_TIME: current_time,
|
||||
ChannelMetadataField.BUFFER_CHUNKS: str(buffer_index),
|
||||
}
|
||||
if new_state == ChannelState.ACTIVE:
|
||||
extra["clients_at_activation"] = str(client_count)
|
||||
|
||||
proxy_server.update_channel_state(channel_id, new_state, extra)
|
||||
logger.info(
|
||||
f"Channel {channel_id} buffer ready ({buffer_index}/{chunks_needed} chunks) "
|
||||
f"-> {new_state} (clients={client_count})"
|
||||
)
|
||||
return new_state
|
||||
|
||||
@staticmethod
|
||||
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None, channel_name=None, stream_name=None):
|
||||
"""
|
||||
|
|
@ -241,34 +313,38 @@ class ChannelService:
|
|||
|
||||
# Store additional metadata if initialization was successful
|
||||
if success and proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
update_data = {}
|
||||
if stream_profile_value:
|
||||
update_data[ChannelMetadataField.STREAM_PROFILE] = stream_profile_value
|
||||
if stream_id:
|
||||
update_data[ChannelMetadataField.STREAM_ID] = str(stream_id)
|
||||
if m3u_profile_id:
|
||||
update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
|
||||
|
||||
# Store channel name and stream name so stats workers don't need DB calls
|
||||
try:
|
||||
if not channel_name:
|
||||
from apps.channels.models import Channel
|
||||
channel_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
|
||||
if channel_name:
|
||||
update_data[ChannelMetadataField.CHANNEL_NAME] = channel_name
|
||||
else:
|
||||
# No channel name means stream preview mode, use stream name as display fallback
|
||||
if stream_id and not stream_name:
|
||||
from apps.channels.models import Stream
|
||||
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
|
||||
if stream_name:
|
||||
update_data[ChannelMetadataField.STREAM_NAME] = stream_name
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store channel/stream names in Redis for {channel_id}: {e}")
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
update_data = {}
|
||||
if stream_profile_value:
|
||||
update_data[ChannelMetadataField.STREAM_PROFILE] = stream_profile_value
|
||||
if stream_id:
|
||||
update_data[ChannelMetadataField.STREAM_ID] = str(stream_id)
|
||||
if m3u_profile_id:
|
||||
update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
|
||||
|
||||
if update_data:
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
|
||||
# Store channel name and stream name so stats workers don't need DB calls
|
||||
try:
|
||||
if not channel_name:
|
||||
from apps.channels.models import Channel
|
||||
channel_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
|
||||
if channel_name:
|
||||
update_data[ChannelMetadataField.CHANNEL_NAME] = channel_name
|
||||
else:
|
||||
# No channel name means stream preview mode, use stream name as display fallback
|
||||
if stream_id and not stream_name:
|
||||
from apps.channels.models import Stream
|
||||
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
|
||||
if stream_name:
|
||||
update_data[ChannelMetadataField.STREAM_NAME] = stream_name
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to store channel/stream names in Redis for {channel_id}: {e}")
|
||||
|
||||
if update_data:
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
|
||||
finally:
|
||||
from django.db import close_old_connections
|
||||
close_old_connections()
|
||||
|
||||
return success
|
||||
|
||||
|
|
@ -736,53 +812,57 @@ class ChannelService:
|
|||
@staticmethod
|
||||
def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None, stream_name=None):
|
||||
"""Update channel metadata in Redis"""
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
try:
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
||||
if not proxy_server.redis_client:
|
||||
return False
|
||||
if not proxy_server.redis_client:
|
||||
return False
|
||||
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
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)
|
||||
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
|
||||
# First check if the key exists and what type it is
|
||||
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
|
||||
metadata = {ChannelMetadataField.URL: url}
|
||||
if user_agent:
|
||||
metadata[ChannelMetadataField.USER_AGENT] = user_agent
|
||||
if stream_id:
|
||||
metadata[ChannelMetadataField.STREAM_ID] = str(stream_id)
|
||||
if not stream_name:
|
||||
try:
|
||||
from apps.channels.models import Stream
|
||||
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update stream name in Redis for stream {stream_id}: {e}")
|
||||
if stream_name:
|
||||
metadata[ChannelMetadataField.STREAM_NAME] = stream_name
|
||||
if m3u_profile_id:
|
||||
metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
|
||||
# Build metadata update dict
|
||||
metadata = {ChannelMetadataField.URL: url}
|
||||
if user_agent:
|
||||
metadata[ChannelMetadataField.USER_AGENT] = user_agent
|
||||
if stream_id:
|
||||
metadata[ChannelMetadataField.STREAM_ID] = str(stream_id)
|
||||
if not stream_name:
|
||||
try:
|
||||
from apps.channels.models import Stream
|
||||
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update stream name in Redis for stream {stream_id}: {e}")
|
||||
if stream_name:
|
||||
metadata[ChannelMetadataField.STREAM_NAME] = stream_name
|
||||
if m3u_profile_id:
|
||||
metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
|
||||
|
||||
# Also update the stream switch time field
|
||||
metadata[ChannelMetadataField.STREAM_SWITCH_TIME] = str(time.time())
|
||||
# Also update the stream switch time field
|
||||
metadata[ChannelMetadataField.STREAM_SWITCH_TIME] = str(time.time())
|
||||
|
||||
# Use the appropriate method based on the key type
|
||||
if key_type == 'hash':
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
elif key_type == 'none': # Key doesn't exist yet
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
else:
|
||||
# If key exists with wrong type, delete it and recreate
|
||||
proxy_server.redis_client.delete(metadata_key)
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
# Use the appropriate method based on the key type
|
||||
if key_type == 'hash':
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
elif key_type == 'none': # Key doesn't exist yet
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
else:
|
||||
# If key exists with wrong type, delete it and recreate
|
||||
proxy_server.redis_client.delete(metadata_key)
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
|
||||
|
||||
# Set switch request flag to ensure all workers see it
|
||||
switch_key = RedisKeys.switch_request(channel_id)
|
||||
proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL
|
||||
# Set switch request flag to ensure all workers see it
|
||||
switch_key = RedisKeys.switch_request(channel_id)
|
||||
proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL
|
||||
|
||||
logger.debug(f"Updated metadata for channel {channel_id} in Redis")
|
||||
return True
|
||||
logger.debug(f"Updated metadata for channel {channel_id} in Redis")
|
||||
return True
|
||||
finally:
|
||||
from django.db import close_old_connections
|
||||
close_old_connections()
|
||||
|
||||
@staticmethod
|
||||
def _publish_stream_switch_event(channel_id, new_url, user_agent=None, stream_id=None, m3u_profile_id=None):
|
||||
|
|
|
|||
142
apps/proxy/live_proxy/tests/test_live_db_cleanup.py
Normal file
142
apps/proxy/live_proxy/tests/test_live_db_cleanup.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Live proxy must release geventpool checkouts after ORM on stream and URL paths."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.test import RequestFactory, SimpleTestCase
|
||||
|
||||
|
||||
class StreamTsDbCleanupTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
@patch("apps.proxy.live_proxy.views.close_old_connections")
|
||||
@patch("apps.proxy.live_proxy.views.create_stream_generator")
|
||||
@patch("apps.proxy.live_proxy.views._resolve_output_format", return_value="mpegts")
|
||||
@patch("apps.proxy.live_proxy.views._resolve_output_profile", return_value=None)
|
||||
@patch("apps.proxy.live_proxy.views.ChannelService.is_channel_unavailable_for_new_clients", return_value=False)
|
||||
@patch("apps.proxy.live_proxy.views.get_stream_object")
|
||||
@patch("apps.proxy.live_proxy.views.network_access_allowed", return_value=True)
|
||||
@patch("apps.proxy.live_proxy.views.ProxyServer")
|
||||
def test_stream_ts_closes_db_before_streaming_response(
|
||||
self,
|
||||
mock_proxy_cls,
|
||||
_network_ok,
|
||||
mock_get_stream_object,
|
||||
_unavailable,
|
||||
_output_profile,
|
||||
_output_format,
|
||||
mock_create_generator,
|
||||
mock_close,
|
||||
):
|
||||
channel = MagicMock()
|
||||
channel.id = 1
|
||||
channel.uuid = "channel-uuid"
|
||||
channel.name = "Test Channel"
|
||||
mock_get_stream_object.return_value = channel
|
||||
|
||||
client_manager = MagicMock()
|
||||
proxy_server = MagicMock()
|
||||
proxy_server.redis_client = MagicMock()
|
||||
proxy_server.redis_client.exists.return_value = True
|
||||
proxy_server.redis_client.hgetall.return_value = {"state": "active"}
|
||||
proxy_server.stream_buffers = {"channel-uuid": MagicMock()}
|
||||
proxy_server.client_managers = {"channel-uuid": client_manager}
|
||||
proxy_server.check_if_channel_exists.return_value = True
|
||||
proxy_server.get_buffer.return_value = MagicMock()
|
||||
mock_proxy_cls.get_instance.return_value = proxy_server
|
||||
|
||||
def _generate():
|
||||
yield b"chunk"
|
||||
|
||||
mock_create_generator.return_value = lambda: _generate()
|
||||
|
||||
request = self.factory.get("/proxy/live/channel-uuid/")
|
||||
request.user = MagicMock(is_authenticated=False)
|
||||
|
||||
from apps.proxy.live_proxy.views import stream_ts
|
||||
|
||||
response = stream_ts(request, "channel-uuid")
|
||||
|
||||
self.assertIsInstance(response, StreamingHttpResponse)
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class UrlUtilsDbCleanupTests(SimpleTestCase):
|
||||
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
|
||||
@patch("apps.proxy.live_proxy.url_utils.get_stream_object")
|
||||
def test_generate_stream_url_closes_db(self, mock_get_object, mock_close):
|
||||
channel = MagicMock()
|
||||
channel.get_stream.return_value = (None, None, "no streams", False)
|
||||
mock_get_object.return_value = channel
|
||||
|
||||
from apps.proxy.live_proxy.url_utils import generate_stream_url
|
||||
|
||||
result = generate_stream_url("channel-uuid")
|
||||
|
||||
self.assertIsNone(result[0])
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
|
||||
@patch("apps.proxy.live_proxy.url_utils.get_stream_object")
|
||||
def test_get_alternate_streams_closes_db(self, mock_get_object, mock_close):
|
||||
channel = MagicMock()
|
||||
channel.streams.all.return_value.order_by.return_value.exists.return_value = False
|
||||
mock_get_object.return_value = channel
|
||||
|
||||
from apps.proxy.live_proxy.url_utils import get_alternate_streams
|
||||
|
||||
result = get_alternate_streams("channel-uuid", current_stream_id=1)
|
||||
|
||||
self.assertEqual(result, [])
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
|
||||
@patch("apps.proxy.live_proxy.url_utils.get_object_or_404")
|
||||
def test_get_stream_info_for_switch_closes_db_on_error(self, mock_get_404, mock_close):
|
||||
mock_get_404.side_effect = RuntimeError("db error")
|
||||
|
||||
from apps.proxy.live_proxy.url_utils import get_stream_info_for_switch
|
||||
|
||||
result = get_stream_info_for_switch("channel-uuid", target_stream_id=99)
|
||||
|
||||
self.assertIn("error", result)
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
|
||||
@patch("apps.proxy.live_proxy.url_utils.M3UAccountProfile.objects.get")
|
||||
def test_get_connections_left_closes_db(self, mock_get, mock_close):
|
||||
mock_get.side_effect = Exception("not found")
|
||||
|
||||
from apps.proxy.live_proxy.url_utils import get_connections_left
|
||||
|
||||
result = get_connections_left(999)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TsGeneratorDbCleanupTests(SimpleTestCase):
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.close_old_connections")
|
||||
@patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer.get_instance")
|
||||
def test_ts_cleanup_closes_db(self, mock_proxy_cls, mock_close):
|
||||
proxy_server = MagicMock()
|
||||
proxy_server.redis_client = None
|
||||
proxy_server.client_managers = {}
|
||||
mock_proxy_cls.return_value = proxy_server
|
||||
|
||||
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
|
||||
|
||||
gen = StreamGenerator.__new__(StreamGenerator)
|
||||
gen.channel_id = "channel-uuid"
|
||||
gen.client_id = "client-1"
|
||||
gen.stream_start_time = 0
|
||||
gen.channel_name = "Test"
|
||||
gen.client_ip = "127.0.0.1"
|
||||
gen.client_user_agent = "agent"
|
||||
gen.bytes_sent = 0
|
||||
gen.user = None
|
||||
|
||||
gen._cleanup()
|
||||
|
||||
mock_close.assert_called_once()
|
||||
|
|
@ -5,6 +5,7 @@ Utilities for handling stream URLs and transformations.
|
|||
import logging
|
||||
import regex
|
||||
from typing import Optional, Tuple, List
|
||||
from django.db import close_old_connections
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.channels.models import Channel, Stream
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
|
|
@ -156,6 +157,8 @@ def generate_stream_url(
|
|||
except Exception as e:
|
||||
logger.error(f"Error generating stream URL: {e}")
|
||||
return None, None, False, None, False, str(e)
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str:
|
||||
"""
|
||||
|
|
@ -300,6 +303,8 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
|
|||
channel.release_stream()
|
||||
logger.error(f"Error getting stream info for switch: {e}", exc_info=True)
|
||||
return {'error': f'Error: {str(e)}'}
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = None) -> List[dict]:
|
||||
"""
|
||||
|
|
@ -422,6 +427,8 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
|
|||
except Exception as e:
|
||||
logger.error(f"Error getting alternate streams for channel {channel_id}: {e}", exc_info=True)
|
||||
return []
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
|
||||
"""
|
||||
|
|
@ -592,3 +599,5 @@ def get_connections_left(m3u_profile_id: int) -> int:
|
|||
except Exception as e:
|
||||
logger.error(f"Error getting connections left for M3U profile {m3u_profile_id}: {e}")
|
||||
return 0
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import time
|
|||
import random
|
||||
import re
|
||||
import pathlib
|
||||
from django.db import close_old_connections
|
||||
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect, HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
|
@ -617,7 +618,9 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
|
|||
)
|
||||
content_type = "video/mp2t"
|
||||
|
||||
# Return the StreamingHttpResponse from the main function
|
||||
# Release ORM checkout before returning a long-lived StreamingHttpResponse.
|
||||
close_old_connections()
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=generate(), content_type=content_type
|
||||
)
|
||||
|
|
|
|||
|
|
@ -243,12 +243,12 @@ class M3UMovieRelation(models.Model):
|
|||
|
||||
def get_stream_url(self):
|
||||
"""Get the full stream URL for this movie from this provider"""
|
||||
# Build URL dynamically for XtreamCodes accounts
|
||||
if self.m3u_account.account_type == 'XC':
|
||||
from core.xtream_codes import Client as XCClient
|
||||
# Use XC client's URL normalization to handle malformed URLs
|
||||
# (e.g., URLs with /player_api.php or query parameters)
|
||||
normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
|
||||
from core.xtream_codes import normalize_server_url
|
||||
|
||||
normalized_url = normalize_server_url(self.m3u_account.server_url)
|
||||
if not normalized_url:
|
||||
return None
|
||||
username = self.m3u_account.username
|
||||
password = self.m3u_account.password
|
||||
return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
|
||||
|
|
@ -292,13 +292,12 @@ class M3UEpisodeRelation(models.Model):
|
|||
|
||||
def get_stream_url(self):
|
||||
"""Get the full stream URL for this episode from this provider"""
|
||||
from core.xtream_codes import Client as XtreamCodesClient
|
||||
|
||||
if self.m3u_account.account_type == 'XC':
|
||||
# For XtreamCodes accounts, build the URL dynamically
|
||||
# Use XC client's URL normalization to handle malformed URLs
|
||||
# (e.g., URLs with /player_api.php or query parameters)
|
||||
normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
|
||||
from core.xtream_codes import normalize_server_url
|
||||
|
||||
normalized_url = normalize_server_url(self.m3u_account.server_url)
|
||||
if not normalized_url:
|
||||
return None
|
||||
username = self.m3u_account.username
|
||||
password = self.m3u_account.password
|
||||
return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@ class ProxySettingsSerializer(serializers.Serializer):
|
|||
|
||||
def validate_channel_init_grace_period(self, value):
|
||||
if value < 0 or value > 60:
|
||||
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
|
||||
raise serializers.ValidationError(
|
||||
"Channel initialization timeout must be between 0 and 60 seconds"
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_new_client_behind_seconds(self, value):
|
||||
|
|
|
|||
|
|
@ -398,12 +398,16 @@ def scan_and_process_files():
|
|||
def _rebuild_programme_indices():
|
||||
"""Queue index builds for active EPG sources that are missing their DB index."""
|
||||
try:
|
||||
from django.db.models import Q
|
||||
from apps.epg.tasks import build_programme_index_task
|
||||
|
||||
sources = EPGSource.objects.filter(
|
||||
is_active=True,
|
||||
programme_index__isnull=True,
|
||||
).exclude(source_type__in=('dummy', 'schedules_direct'))
|
||||
).exclude(
|
||||
source_type__in=('dummy', 'schedules_direct')
|
||||
).filter(
|
||||
Q(index_record__isnull=True) | Q(index_record__data__isnull=True)
|
||||
)
|
||||
|
||||
count = 0
|
||||
for source in sources:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, SimpleTestCase
|
||||
|
||||
from apps.epg.models import EPGSource
|
||||
from apps.epg.models import EPGSource, EPGSourceIndex
|
||||
from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY
|
||||
|
||||
|
||||
|
|
@ -37,14 +37,10 @@ class DispatcharrUserAgentTests(TestCase):
|
|||
|
||||
class ProgrammeIndexRebuildTests(TestCase):
|
||||
def test_startup_rebuild_does_not_lock_out_queued_build_task(self):
|
||||
EPGSource.objects.update(
|
||||
programme_index={"channels": {}, "interleaved_channels": []}
|
||||
)
|
||||
source = EPGSource.objects.create(
|
||||
name="Missing Index",
|
||||
source_type="xmltv",
|
||||
is_active=True,
|
||||
programme_index=None,
|
||||
)
|
||||
|
||||
class FakeRedis:
|
||||
|
|
@ -301,3 +297,14 @@ class DropDBCommandTlsTest(TestCase):
|
|||
host='localhost', port=5432,
|
||||
autocommit=True,
|
||||
)
|
||||
|
||||
|
||||
class MallocTrimTests(SimpleTestCase):
|
||||
def test_trim_is_noop_when_libc_has_no_malloc_trim(self):
|
||||
from core.utils import trim_c_allocator_heap
|
||||
|
||||
fake_libc = MagicMock(spec=[])
|
||||
with patch('ctypes.util.find_library', return_value='libc.so.6'), patch(
|
||||
'ctypes.CDLL', return_value=fake_libc
|
||||
):
|
||||
self.assertFalse(trim_c_allocator_heap())
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import redis
|
||||
import logging
|
||||
import time
|
||||
|
|
@ -7,7 +8,6 @@ from pathlib import Path
|
|||
import re
|
||||
from django.conf import settings
|
||||
from redis.exceptions import ConnectionError, TimeoutError
|
||||
from django.core.cache import cache
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.validators import URLValidator
|
||||
|
|
@ -71,6 +71,46 @@ def natural_sort_key(text):
|
|||
|
||||
return [convert(c) for c in re.split('([0-9]+)', text)]
|
||||
|
||||
|
||||
def custom_properties_as_dict(value):
|
||||
"""
|
||||
Normalize a JSONField-backed custom_properties value into a dict.
|
||||
|
||||
Historical rows (TextField era and early JSONField migration) may store a
|
||||
JSON-encoded string instead of an object. API clients can also submit a
|
||||
string value because JSONField accepts any JSON type. Call this before
|
||||
reading or merging custom_properties.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
"custom_properties stored as non-JSON string; ignoring: %r",
|
||||
value[:100],
|
||||
)
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
if value is None:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def ensure_custom_properties_dict(value):
|
||||
"""
|
||||
Return a dict for read/merge/bulk-write paths. Dict values pass through
|
||||
without re-parsing. Use model ``save()`` (not this) as the canonical
|
||||
normalizer for ORM writes that go through ``save()``.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if value is None:
|
||||
return {}
|
||||
return custom_properties_as_dict(value)
|
||||
|
||||
|
||||
class RedisClient:
|
||||
_client = None
|
||||
_buffer = None
|
||||
|
|
@ -506,6 +546,25 @@ def monitor_memory_usage(func):
|
|||
return result
|
||||
return wrapper
|
||||
|
||||
def trim_c_allocator_heap():
|
||||
"""Return unused C heap pages to the OS where supported (glibc malloc_trim)."""
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
|
||||
libc_name = ctypes.util.find_library("c")
|
||||
if not libc_name:
|
||||
return False
|
||||
libc = ctypes.CDLL(libc_name)
|
||||
if not hasattr(libc, "malloc_trim"):
|
||||
return False
|
||||
libc.malloc_trim(0)
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("malloc_trim unavailable or failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_memory(log_usage=False, force_collection=True):
|
||||
"""
|
||||
Comprehensive memory cleanup function to reduce memory footprint
|
||||
|
|
@ -549,6 +608,30 @@ def cleanup_memory(log_usage=False, force_collection=True):
|
|||
pass
|
||||
logger.trace("Memory cleanup complete for django")
|
||||
|
||||
|
||||
def spawn_memory_trim(close_connections=False):
|
||||
"""Reclaim a request's heap pages: GC, then return freed C pages to the OS.
|
||||
|
||||
On gevent uWSGI workers the trim runs in a spawned greenlet so it never
|
||||
blocks the caller; Celery prefork workers (no gevent hub) run it inline.
|
||||
Set close_connections=True when called from a streaming generator's teardown
|
||||
so the pooled DB connection is released first.
|
||||
"""
|
||||
def _run():
|
||||
cleanup_memory(force_collection=True)
|
||||
trim_c_allocator_heap()
|
||||
|
||||
if close_connections:
|
||||
from django.db import close_old_connections
|
||||
close_old_connections()
|
||||
|
||||
if _is_gevent_monkey_patched():
|
||||
import gevent
|
||||
gevent.spawn(_run)
|
||||
else:
|
||||
_run()
|
||||
|
||||
|
||||
def safe_upload_path(filename: str, base_dir) -> str:
|
||||
"""Return a safe absolute path for an uploaded file within base_dir.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,26 @@ import json
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_server_url(url):
|
||||
"""Normalize server URL: strip XC API endpoints and query params, preserve base path."""
|
||||
if not url:
|
||||
return url
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(url.strip())
|
||||
path = parsed.path.rstrip('/')
|
||||
|
||||
# XC API endpoints are always .php files; legitimate base paths never are.
|
||||
# Stripping the trailing segment when it ends in .php handles any pasted API URL.
|
||||
last_segment = path.rsplit('/', 1)[-1]
|
||||
if last_segment.endswith('.php'):
|
||||
path = path[:-(len(last_segment) + 1)] if '/' in path else ''
|
||||
|
||||
return urlunparse((parsed.scheme, parsed.netloc, path, '', '', ''))
|
||||
|
||||
|
||||
class Client:
|
||||
"""Xtream Codes API Client with robust error handling"""
|
||||
|
||||
|
|
@ -43,22 +63,10 @@ class Client:
|
|||
self.server_info = None
|
||||
|
||||
def _normalize_url(self, url):
|
||||
"""Normalize server URL: strip XC API endpoints and query params, preserve base path."""
|
||||
if not url:
|
||||
normalized = normalize_server_url(url)
|
||||
if not normalized:
|
||||
raise ValueError("Server URL cannot be empty")
|
||||
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(url.strip())
|
||||
path = parsed.path.rstrip('/')
|
||||
|
||||
# XC API endpoints are always .php files; legitimate base paths never are.
|
||||
# Stripping the trailing segment when it ends in .php handles any pasted API URL.
|
||||
last_segment = path.rsplit('/', 1)[-1]
|
||||
if last_segment.endswith('.php'):
|
||||
path = path[:-(len(last_segment) + 1)] if '/' in path else ''
|
||||
|
||||
return urlunparse((parsed.scheme, parsed.netloc, path, '', '', ''))
|
||||
return normalized
|
||||
|
||||
def _make_request(self, endpoint, params=None):
|
||||
"""Make request with detailed error handling"""
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import os
|
||||
from celery import Celery
|
||||
import logging
|
||||
from celery.signals import task_postrun, worker_ready
|
||||
from celery.signals import task_postrun, task_prerun, worker_ready
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -68,18 +68,30 @@ app.conf.task_routes = {
|
|||
'apps.channels.tasks.run_recording': {'queue': 'dvr'},
|
||||
}
|
||||
|
||||
|
||||
@task_prerun.connect
|
||||
def reset_db_connection_before_task(**kwargs):
|
||||
"""Discard stale DB connections before each task (Celery workers are long-lived)."""
|
||||
from django.db import close_old_connections
|
||||
|
||||
try:
|
||||
close_old_connections()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Add memory cleanup after task completion
|
||||
@task_postrun.connect # Use the imported signal
|
||||
def cleanup_task_memory(**kwargs):
|
||||
"""Clean up memory and database connections after each task completes"""
|
||||
from django.db import connection
|
||||
from django.db import close_old_connections
|
||||
|
||||
# Get task name from kwargs
|
||||
task_name = kwargs.get('task').name if kwargs.get('task') else ''
|
||||
|
||||
# Close database connection for this Celery worker process
|
||||
# Return all DB connections to the pool in a clean state
|
||||
try:
|
||||
connection.close()
|
||||
close_old_connections()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
58
dispatcharr/settings_test.py
Normal file
58
dispatcharr/settings_test.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""
|
||||
Django settings for running the backend test suite in isolation.
|
||||
|
||||
Always use this module instead of dispatcharr.settings when running tests:
|
||||
|
||||
python manage.py test
|
||||
|
||||
`manage.py` selects this module automatically for the ``test`` command.
|
||||
|
||||
Django creates a separate empty database (``test_<POSTGRES_DB>``) and runs
|
||||
migrations — your live data under /data/db is not used.
|
||||
|
||||
Why not dispatcharr.settings?
|
||||
- Production/AIO points at the live ``dispatcharr`` database.
|
||||
- django-db-geventpool breaks TestCase transaction isolation on pooled connections.
|
||||
|
||||
SQLite (``TEST_USE_SQLITE=1``) is an optional fallback for machines without
|
||||
Postgres; production and CI should use the default Postgres test database.
|
||||
"""
|
||||
import os
|
||||
|
||||
from dispatcharr.settings import * # noqa: F401,F403
|
||||
|
||||
# Fast password hashing for tests.
|
||||
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||||
|
||||
# Do NOT run Celery tasks inline during tests. post_save signals on M3UAccount and
|
||||
# EPGSource call .delay(); eager mode runs them inside TestCase transactions and
|
||||
# closes/poisons the DB connection for subsequent queries in the same test.
|
||||
CELERY_TASK_ALWAYS_EAGER = False
|
||||
CELERY_TASK_EAGER_PROPAGATES = False
|
||||
|
||||
_use_sqlite = os.environ.get("TEST_USE_SQLITE", "").lower() in ("1", "true", "yes")
|
||||
|
||||
if _use_sqlite:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": ":memory:",
|
||||
}
|
||||
}
|
||||
else:
|
||||
# Default: PostgreSQL with Django-managed test_dispatcharr (matches production).
|
||||
# Uses the standard backend (not geventpool) so TestCase transactions isolate.
|
||||
_pg_name = os.environ.get("POSTGRES_DB", "dispatcharr")
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": _pg_name,
|
||||
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
|
||||
"TEST": {
|
||||
"NAME": "test_" + _pg_name,
|
||||
},
|
||||
}
|
||||
}
|
||||
240
frontend/package-lock.json
generated
240
frontend/package-lock.json
generated
|
|
@ -57,7 +57,7 @@
|
|||
"globals": "^15.15.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
},
|
||||
|
|
@ -595,9 +595,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -612,9 +612,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -629,9 +629,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -646,9 +646,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -663,9 +663,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -680,9 +680,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -697,9 +697,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -714,9 +714,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -731,9 +731,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -748,9 +748,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -765,9 +765,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -782,9 +782,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -799,9 +799,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -816,9 +816,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -833,9 +833,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -850,9 +850,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -867,9 +867,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -884,9 +884,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -901,9 +901,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -918,9 +918,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -935,9 +935,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -952,9 +952,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -969,9 +969,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -986,9 +986,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1003,9 +1003,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -1020,9 +1020,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3163,9 +3163,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
|
@ -3176,32 +3176,32 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
|
|
@ -3814,16 +3814,26 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.1.0.tgz",
|
||||
"integrity": "sha512-s8VA5jkR8f22S3NAXmhKPFqGUduqZGlsufabVOgN14iTdw/RXcym7bKkbwjxLK9Yw2lEvvmJjFp119+KPeo8Kg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
"js-yaml": "bin/js-yaml.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
|
|
@ -5425,9 +5435,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -61,16 +61,17 @@
|
|||
"globals": "^15.15.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"resolutions": {
|
||||
"vite": "7.1.7",
|
||||
"vite": "7.3.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"js-yaml": "^4.1.1",
|
||||
"esbuild": "^0.28.1",
|
||||
"js-yaml": "^5.1.0",
|
||||
"minimatch": "^10.2.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ export const WebsocketProvider = ({ children }) => {
|
|||
notifications.update({
|
||||
id,
|
||||
title: 'Commercials removed',
|
||||
message: `${title} — kept ${parsedEvent.data.segments_kept} segments`,
|
||||
message: `${title}: kept ${parsedEvent.data.segments_kept} segments`,
|
||||
color: 'green.5',
|
||||
loading: false,
|
||||
autoClose: 4000,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ import {
|
|||
savePlayerPrefs,
|
||||
} from '../utils/components/FloatingVideoUtils.js';
|
||||
|
||||
// Native <video src> cannot send Authorization headers. Append ?token= at playback
|
||||
// time (not when building the URL in cards/modals) so the JWT is fresh. hls.js
|
||||
// paths authenticate via xhrSetup instead and do not need this helper.
|
||||
const withRecordingAuthToken = (url) => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
if (!url.includes('/api/channels/recordings/')) return url;
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (!token) return url;
|
||||
const [base, query = ''] = url.split('?');
|
||||
const params = new URLSearchParams(query);
|
||||
params.set('token', token);
|
||||
return `${base}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const ResizeHandles = ({ startResize }) => {
|
||||
const HANDLE_SIZE = 18;
|
||||
const HANDLE_OFFSET = 0;
|
||||
|
|
@ -333,72 +347,75 @@ export default function FloatingVideo() {
|
|||
let hls = null;
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
// Open at the very beginning of the recording rather than the live
|
||||
// edge. Without this, an in-progress recording would start at "now"
|
||||
// and hide everything already recorded. hls.js applies this AFTER
|
||||
// MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is
|
||||
// also kept as a safety net for the Safari native-HLS path and for
|
||||
// edge cases where this initial-position logic loses to the user's
|
||||
// first interaction.
|
||||
startPosition: 0,
|
||||
// Allow seeking back to the start of the recording, regardless of
|
||||
// current playhead position. Recordings can be hours long and the
|
||||
// user may want to scrub anywhere; we explicitly disable buffer
|
||||
// eviction by setting a very large back-buffer length.
|
||||
backBufferLength: 90 * 60, // 90 minutes
|
||||
maxBufferLength: 60,
|
||||
maxMaxBufferLength: 600,
|
||||
// For an in-progress recording, hls.js refreshes the playlist on
|
||||
// its target-duration cadence; let it follow the live edge but keep
|
||||
// the full DVR window seekable.
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 10,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// Inject the JWT into every playlist + segment XHR. Read the token
|
||||
// from the auth store at request time rather than capturing the
|
||||
// closure value at hls.js init, so a refreshed access token mid-
|
||||
// playback is picked up on the next segment fetch.
|
||||
xhrSetup: (xhr) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
if (data.fatal) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('HLS fatal error:', data.type, data.details);
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
try {
|
||||
hls.startLoad();
|
||||
} catch {
|
||||
// ignore
|
||||
try {
|
||||
hls = new Hls({
|
||||
// Open at the very beginning of the recording rather than the live
|
||||
// edge. Without this, an in-progress recording would start at "now"
|
||||
// and hide everything already recorded. hls.js applies this AFTER
|
||||
// MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is
|
||||
// also kept as a safety net for the Safari native-HLS path and for
|
||||
// edge cases where this initial-position logic loses to the user's
|
||||
// first interaction.
|
||||
startPosition: 0,
|
||||
// Allow seeking back to the start of the recording, regardless of
|
||||
// current playhead position. Recordings can be hours long and the
|
||||
// user may want to scrub anywhere; we explicitly disable buffer
|
||||
// eviction by setting a very large back-buffer length.
|
||||
backBufferLength: 90 * 60, // 90 minutes
|
||||
maxBufferLength: 60,
|
||||
maxMaxBufferLength: 600,
|
||||
// Leave liveMaxLatencyDurationCount at the hls.js default (Infinity).
|
||||
// A finite value forces the playhead to the live edge during playback.
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// Inject the JWT into every playlist + segment XHR. Read the token
|
||||
// from the auth store at request time rather than capturing the
|
||||
// closure value at hls.js init, so a refreshed access token mid-
|
||||
// playback is picked up on the next segment fetch.
|
||||
xhrSetup: (xhr) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
try {
|
||||
hls.recoverMediaError();
|
||||
} catch {
|
||||
// ignore
|
||||
},
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
if (data.fatal) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('HLS fatal error:', data.type, data.details);
|
||||
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
try {
|
||||
hls.startLoad();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
try {
|
||||
hls.recoverMediaError();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
setLoadError(`HLS playback error: ${data.details || data.type}`);
|
||||
}
|
||||
} else {
|
||||
setLoadError(`HLS playback error: ${data.details || data.type}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
hls.loadSource(streamUrl);
|
||||
});
|
||||
});
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
hls.loadSource(streamUrl);
|
||||
});
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
setLoadError(`HLS initialization error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari path: native HLS support, including seekable DVR windows.
|
||||
video.src = streamUrl;
|
||||
video.src = withRecordingAuthToken(streamUrl);
|
||||
video.load();
|
||||
} else {
|
||||
// Plain progressive file (MKV/MP4): native HTML5.
|
||||
video.src = streamUrl;
|
||||
video.src = withRecordingAuthToken(streamUrl);
|
||||
video.load();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,49 @@ vi.mock('mpegts.js', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
const mockHlsInstance = {
|
||||
attachMedia: vi.fn(),
|
||||
loadSource: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
let capturedHlsConfig = null;
|
||||
let forceHlsInitError = false;
|
||||
|
||||
vi.mock('hls.js', () => ({
|
||||
default: class MockHls {
|
||||
static isSupported = vi.fn(() => true);
|
||||
|
||||
static Events = {
|
||||
ERROR: 'error',
|
||||
MEDIA_ATTACHED: 'media_attached',
|
||||
};
|
||||
|
||||
static ErrorTypes = {
|
||||
NETWORK_ERROR: 'networkError',
|
||||
MEDIA_ERROR: 'mediaError',
|
||||
};
|
||||
|
||||
constructor(config) {
|
||||
if (forceHlsInitError) {
|
||||
throw new Error('Illegal hls.js config');
|
||||
}
|
||||
capturedHlsConfig = config;
|
||||
Object.assign(this, mockHlsInstance);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store/auth', () => ({
|
||||
default: {
|
||||
getState: vi.fn(() => ({ accessToken: 'test-token' })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import the mocked module after mocking
|
||||
const mpegts = (await import('mpegts.js')).default;
|
||||
const Hls = (await import('hls.js')).default;
|
||||
|
||||
// Mock react-draggable
|
||||
vi.mock('react-draggable', () => ({
|
||||
|
|
@ -53,6 +94,8 @@ describe('FloatingVideo', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
capturedHlsConfig = null;
|
||||
forceHlsInitError = false;
|
||||
|
||||
// Mock HTMLVideoElement methods
|
||||
HTMLVideoElement.prototype.load = vi.fn();
|
||||
|
|
@ -239,6 +282,57 @@ describe('FloatingVideo', () => {
|
|||
expect(video.poster).toBe('http://example.com/poster.jpg');
|
||||
});
|
||||
|
||||
it('should disable live-edge sync for in-progress recording HLS', () => {
|
||||
useVideoStore.mockImplementation((selector) => {
|
||||
const state = {
|
||||
isVisible: true,
|
||||
streamUrl:
|
||||
'http://example.com/api/channels/recordings/1/hls/index.m3u8',
|
||||
contentType: 'vod',
|
||||
metadata: { name: 'News Recording' },
|
||||
hideVideo: mockHideVideo,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
Hls.isSupported.mockReturnValue(true);
|
||||
|
||||
render(<FloatingVideo />);
|
||||
|
||||
expect(capturedHlsConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
startPosition: 0,
|
||||
})
|
||||
);
|
||||
expect(capturedHlsConfig).not.toHaveProperty(
|
||||
'liveMaxLatencyDurationCount'
|
||||
);
|
||||
expect(capturedHlsConfig).not.toHaveProperty('liveSyncDurationCount');
|
||||
});
|
||||
|
||||
it('shows an in-player error when hls.js config is invalid', () => {
|
||||
useVideoStore.mockImplementation((selector) => {
|
||||
const state = {
|
||||
isVisible: true,
|
||||
streamUrl:
|
||||
'http://example.com/api/channels/recordings/1/hls/index.m3u8',
|
||||
contentType: 'vod',
|
||||
metadata: { name: 'News Recording' },
|
||||
hideVideo: mockHideVideo,
|
||||
};
|
||||
return selector ? selector(state) : state;
|
||||
});
|
||||
|
||||
Hls.isSupported.mockReturnValue(true);
|
||||
forceHlsInitError = true;
|
||||
|
||||
render(<FloatingVideo />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/HLS initialization error: Illegal hls.js config/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show metadata overlay', () => {
|
||||
const { container } = render(<FloatingVideo />);
|
||||
const video = container.querySelector('video');
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
getRegexOptions,
|
||||
getStreamsRegexPreview,
|
||||
isExpectedOccupantForGroup,
|
||||
effectiveSyncGroupId,
|
||||
isGroupVisible,
|
||||
rangeFor,
|
||||
} from '../../utils/forms/LiveGroupFilterUtils.js';
|
||||
|
|
@ -131,7 +132,12 @@ const LiveGroupFilter = ({
|
|||
// (in-memory range overlap with sibling groups) sources so the sweep
|
||||
// can refresh form-overlap synchronously without firing HTTP for
|
||||
// groups that did not change.
|
||||
const scheduleConflictScan = (groupId, rawStart, rawEnd) => {
|
||||
const scheduleConflictScan = (
|
||||
groupId,
|
||||
rawStart,
|
||||
rawEnd,
|
||||
expectedGroupId = groupId
|
||||
) => {
|
||||
if (conflictTimersRef.current[groupId]) {
|
||||
clearTimeout(conflictTimersRef.current[groupId]);
|
||||
}
|
||||
|
|
@ -156,7 +162,7 @@ const LiveGroupFilter = ({
|
|||
? result.occupants
|
||||
: [];
|
||||
const unexpected = occupants.filter(
|
||||
(o) => !isExpectedOccupantForGroup(o, groupId, playlist)
|
||||
(o) => !isExpectedOccupantForGroup(o, expectedGroupId, playlist)
|
||||
);
|
||||
setConflictSource(groupId, 'occupant', unexpected.length > 0);
|
||||
} catch (e) {
|
||||
|
|
@ -221,7 +227,8 @@ const LiveGroupFilter = ({
|
|||
scheduleConflictScan(
|
||||
g.channel_group,
|
||||
range.startRaw,
|
||||
g.auto_sync_channel_end
|
||||
g.auto_sync_channel_end,
|
||||
effectiveSyncGroupId(g)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@ export const PROXY_SETTINGS_OPTIONS = {
|
|||
'Delay in seconds before shutting down a channel after last client disconnects',
|
||||
},
|
||||
channel_init_grace_period: {
|
||||
label: 'Channel Initialization Grace Period',
|
||||
description: 'Grace period in seconds during channel initialization',
|
||||
label: 'Channel Initialization Timeout',
|
||||
description:
|
||||
'Maximum seconds to wait for the initial buffer to fill while a channel is connecting. Channels that never receive enough buffered data are stopped after this limit.',
|
||||
},
|
||||
new_client_behind_seconds: {
|
||||
label: 'New Client Buffer (seconds)',
|
||||
|
|
|
|||
|
|
@ -272,34 +272,36 @@ describe('RecordingCardUtils', () => {
|
|||
|
||||
describe('getRecordingUrl', () => {
|
||||
it('returns file_url when available', () => {
|
||||
const customProps = { file_url: '/recordings/file.mp4' };
|
||||
const customProps = { file_url: '/api/channels/recordings/67/file/' };
|
||||
const result = getRecordingUrl(customProps, 'production');
|
||||
|
||||
expect(result).toBe('/recordings/file.mp4');
|
||||
expect(result).toBe('/api/channels/recordings/67/file/');
|
||||
});
|
||||
|
||||
it('returns output_file_url when file_url is not available', () => {
|
||||
const customProps = { output_file_url: '/output/file.mp4' };
|
||||
const customProps = { output_file_url: '/api/channels/recordings/1/file/' };
|
||||
const result = getRecordingUrl(customProps, 'production');
|
||||
|
||||
expect(result).toBe('/output/file.mp4');
|
||||
expect(result).toBe('/api/channels/recordings/1/file/');
|
||||
});
|
||||
|
||||
it('prefers file_url over output_file_url', () => {
|
||||
const customProps = {
|
||||
file_url: '/recordings/file.mp4',
|
||||
output_file_url: '/output/file.mp4',
|
||||
file_url: '/api/channels/recordings/2/file/',
|
||||
output_file_url: '/api/channels/recordings/3/file/',
|
||||
};
|
||||
const result = getRecordingUrl(customProps, 'production');
|
||||
|
||||
expect(result).toBe('/recordings/file.mp4');
|
||||
expect(result).toBe('/api/channels/recordings/2/file/');
|
||||
});
|
||||
|
||||
it('prepends dev server URL in dev mode for relative paths', () => {
|
||||
const customProps = { file_url: '/recordings/file.mp4' };
|
||||
const customProps = { file_url: '/api/channels/recordings/4/hls/index.m3u8' };
|
||||
const result = getRecordingUrl(customProps, 'dev');
|
||||
|
||||
expect(result).toMatch(/^https?:\/\/.*:5656\/recordings\/file\.mp4$/);
|
||||
expect(result).toMatch(
|
||||
/^https?:\/\/.*:5656\/api\/channels\/recordings\/4\/hls\/index\.m3u8$/
|
||||
);
|
||||
});
|
||||
|
||||
it('does not prepend dev URL for absolute URLs', () => {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ export const isExpectedOccupantForGroup = (
|
|||
);
|
||||
};
|
||||
|
||||
// The group the sync's own channels actually land in. A group_override
|
||||
// routes auto-created channels into a different ChannelGroup, so the
|
||||
// conflict check must recognize occupants of that target group as this
|
||||
// config's own output rather than flagging them against the source group.
|
||||
export const effectiveSyncGroupId = (group) => {
|
||||
const override = group?.custom_properties?.group_override;
|
||||
if (override !== undefined && override !== null && override !== '') {
|
||||
return Number(override);
|
||||
}
|
||||
return group?.channel_group;
|
||||
};
|
||||
|
||||
export const rangeFor = (g) => {
|
||||
if (!g.enabled || !g.auto_channel_sync) return null;
|
||||
const mode = g.custom_properties?.channel_numbering_mode || 'fixed';
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getChannelsInRange,
|
||||
getStreamsRegexPreview,
|
||||
isExpectedOccupantForGroup,
|
||||
effectiveSyncGroupId,
|
||||
rangeFor,
|
||||
abortTimers,
|
||||
getRegexOptions,
|
||||
|
|
@ -227,6 +228,116 @@ describe('LiveGroupFilterUtils', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ── effectiveSyncGroupId ───────────────────────────────────────────────────
|
||||
describe('effectiveSyncGroupId', () => {
|
||||
it('returns the source channel_group when there is no override', () => {
|
||||
expect(effectiveSyncGroupId(makeGroup({ channel_group: 7 }))).toBe(7);
|
||||
});
|
||||
|
||||
it('returns the group_override target when set', () => {
|
||||
const group = makeGroup({
|
||||
channel_group: 7,
|
||||
custom_properties: { group_override: 9 },
|
||||
});
|
||||
expect(effectiveSyncGroupId(group)).toBe(9);
|
||||
});
|
||||
|
||||
it('coerces a string-stored group_override to a number', () => {
|
||||
const group = makeGroup({
|
||||
channel_group: 7,
|
||||
custom_properties: { group_override: '9' },
|
||||
});
|
||||
expect(effectiveSyncGroupId(group)).toBe(9);
|
||||
});
|
||||
|
||||
it('falls back to the source group when group_override is blank', () => {
|
||||
const group = makeGroup({
|
||||
channel_group: 7,
|
||||
custom_properties: { group_override: '' },
|
||||
});
|
||||
expect(effectiveSyncGroupId(group)).toBe(7);
|
||||
});
|
||||
|
||||
// Regression guard for the group-override range-conflict false positive:
|
||||
// the auto-sync's own channels land in the override target group, so
|
||||
// comparing against the source group (pre-fix) flags them as a conflict,
|
||||
// while comparing against the effective target recognizes them as this
|
||||
// config's own output.
|
||||
it("makes group-override occupants count as this group's own", () => {
|
||||
const group = makeGroup({
|
||||
channel_group: 7,
|
||||
custom_properties: { group_override: 9 },
|
||||
});
|
||||
const occupant = makeOccupant({ channel_group_id: 9 });
|
||||
// Pre-fix comparison (source group) treats own channels as a conflict.
|
||||
expect(
|
||||
isExpectedOccupantForGroup(
|
||||
occupant,
|
||||
group.channel_group,
|
||||
makePlaylist()
|
||||
)
|
||||
).toBe(false);
|
||||
// Comparing against the effective target recognizes them as expected.
|
||||
expect(
|
||||
isExpectedOccupantForGroup(
|
||||
occupant,
|
||||
effectiveSyncGroupId(group),
|
||||
makePlaylist()
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// Guards against over-suppression: resolving the effective target group
|
||||
// must still surface genuine collisions in an override config's range.
|
||||
// Only the config's own output (auto-created, this account, in the
|
||||
// target group, unpinned) is excluded.
|
||||
it('still flags genuine collisions in a group-override config', () => {
|
||||
const group = makeGroup({
|
||||
channel_group: 7,
|
||||
custom_properties: { group_override: 9 },
|
||||
});
|
||||
const target = effectiveSyncGroupId(group);
|
||||
// Manual channel sitting in the range.
|
||||
expect(
|
||||
isExpectedOccupantForGroup(
|
||||
makeOccupant({ channel_group_id: 9, auto_created: false }),
|
||||
target,
|
||||
makePlaylist()
|
||||
)
|
||||
).toBe(false);
|
||||
// Auto-created by a different account.
|
||||
expect(
|
||||
isExpectedOccupantForGroup(
|
||||
makeOccupant({
|
||||
channel_group_id: 9,
|
||||
auto_created_by_account_id: 999,
|
||||
}),
|
||||
target,
|
||||
makePlaylist()
|
||||
)
|
||||
).toBe(false);
|
||||
// A channel in a different group than the override target.
|
||||
expect(
|
||||
isExpectedOccupantForGroup(
|
||||
makeOccupant({ channel_group_id: 123 }),
|
||||
target,
|
||||
makePlaylist()
|
||||
)
|
||||
).toBe(false);
|
||||
// A user-pinned channel number.
|
||||
expect(
|
||||
isExpectedOccupantForGroup(
|
||||
makeOccupant({
|
||||
channel_group_id: 9,
|
||||
has_channel_number_override: true,
|
||||
}),
|
||||
target,
|
||||
makePlaylist()
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── rangeFor ──────────────────────────────────────────────────────────────
|
||||
describe('rangeFor', () => {
|
||||
it('returns null when group is disabled', () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import sys
|
|||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings')
|
||||
# Use isolated test DB settings for `manage.py test` (empty test_<dbname>).
|
||||
# Override with --settings=... on the command line if needed.
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "test":
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "dispatcharr.settings_test"
|
||||
else:
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dispatcharr.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
|
|
|
|||
74
tests/test_custom_properties_as_dict.py
Normal file
74
tests/test_custom_properties_as_dict.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import uuid
|
||||
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
from core.utils import custom_properties_as_dict, ensure_custom_properties_dict
|
||||
from apps.m3u.models import M3UAccount, M3UAccountProfile
|
||||
from apps.channels.models import ChannelGroupM3UAccount, ChannelGroup
|
||||
|
||||
|
||||
class CustomPropertiesAsDictTests(SimpleTestCase):
|
||||
def test_dict_passthrough(self):
|
||||
value = {"enable_vod": True}
|
||||
self.assertIs(custom_properties_as_dict(value), value)
|
||||
|
||||
def test_json_string_parsed(self):
|
||||
self.assertEqual(
|
||||
custom_properties_as_dict('{"enable_vod": true}'),
|
||||
{"enable_vod": True},
|
||||
)
|
||||
|
||||
def test_non_json_string_returns_empty_dict(self):
|
||||
self.assertEqual(custom_properties_as_dict("not-json"), {})
|
||||
|
||||
def test_json_array_returns_empty_dict(self):
|
||||
self.assertEqual(custom_properties_as_dict("[1, 2]"), {})
|
||||
|
||||
def test_none_returns_empty_dict(self):
|
||||
self.assertEqual(custom_properties_as_dict(None), {})
|
||||
|
||||
|
||||
class EnsureCustomPropertiesDictTests(SimpleTestCase):
|
||||
def test_dict_passthrough_without_reparse(self):
|
||||
value = {"enable_vod": True}
|
||||
self.assertIs(ensure_custom_properties_dict(value), value)
|
||||
|
||||
def test_none_returns_empty_dict(self):
|
||||
self.assertEqual(ensure_custom_properties_dict(None), {})
|
||||
|
||||
|
||||
class CustomPropertiesSaveNormalizationTests(TestCase):
|
||||
def test_m3u_account_save_rewrites_string_custom_properties(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name=f"Test Account {uuid.uuid4().hex[:8]}",
|
||||
custom_properties='{"enable_vod": true}',
|
||||
)
|
||||
account.refresh_from_db()
|
||||
self.assertEqual(account.custom_properties, {"enable_vod": True})
|
||||
|
||||
def test_profile_save_rewrites_string_custom_properties(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name=f"Test Account {uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
profile = M3UAccountProfile.objects.get(
|
||||
m3u_account=account, is_default=True
|
||||
)
|
||||
profile.custom_properties = '{"notes": "hello"}'
|
||||
profile.save(update_fields=["custom_properties"])
|
||||
profile.refresh_from_db()
|
||||
self.assertEqual(profile.custom_properties, {"notes": "hello"})
|
||||
|
||||
def test_group_relation_save_rewrites_string_custom_properties(self):
|
||||
account = M3UAccount.objects.create(
|
||||
name=f"Test Account {uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
group = ChannelGroup.objects.create(
|
||||
name=f"Sports {uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
rel = ChannelGroupM3UAccount.objects.create(
|
||||
m3u_account=account,
|
||||
channel_group=group,
|
||||
custom_properties='{"force_dummy_epg": true}',
|
||||
)
|
||||
rel.refresh_from_db()
|
||||
self.assertEqual(rel.custom_properties, {"force_dummy_epg": True})
|
||||
|
|
@ -17,13 +17,13 @@ class ProcessLabelTests(SimpleTestCase):
|
|||
self.assertEqual(role, "uwsgi")
|
||||
|
||||
def test_uwsgi_labeled_when_worker_module_present(self):
|
||||
fake_uwsgi = type("uwsgi", (), {"worker_id": lambda: 2})()
|
||||
fake_uwsgi = type("uwsgi", (), {"worker_id": staticmethod(lambda: 2)})()
|
||||
with patch.dict("sys.modules", {"uwsgi": fake_uwsgi}):
|
||||
role = get_process_role(["/dispatcharrpy/bin/python", "-c", "pass"])
|
||||
self.assertEqual(role, "uwsgi")
|
||||
|
||||
def test_uwsgi_master_not_labeled_as_uwsgi(self):
|
||||
fake_uwsgi = type("uwsgi", (), {"worker_id": lambda: 0})()
|
||||
fake_uwsgi = type("uwsgi", (), {"worker_id": staticmethod(lambda: 0)})()
|
||||
with patch.dict("sys.modules", {"uwsgi": fake_uwsgi}):
|
||||
role = get_process_role(["/dispatcharrpy/bin/python", "-c", "pass"])
|
||||
self.assertEqual(role, "django")
|
||||
|
|
|
|||
|
|
@ -128,13 +128,13 @@ class UserPreferencesAPITests(TestCase):
|
|||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_patch_me_cannot_escalate_privileges(self):
|
||||
"""Test PATCH /me/ rejects attempts to change user_level or is_staff"""
|
||||
"""PATCH /me/ ignores privilege fields; they are stripped before save."""
|
||||
original_level = self.user.user_level
|
||||
|
||||
data = {"user_level": 99, "is_staff": True, "is_superuser": True}
|
||||
response = self.client.patch(self.me_url, data, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.user_level, original_level)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue