diff --git a/.gitignore b/.gitignore index c75198a0..6d6b1613 100755 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ celerybeat-schedule* dump.rdb debugpy* uwsgi.sock +.uwsgi-reload package-lock.json models .idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 325ed735..119e3f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,22 +10,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb) -- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{stream_id}/{timestamp}/{duration}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to clients such as iPlayTV (Apple TV) and TiviMate. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux) - - **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/?session_id=...` for headerless video players. `DELETE /api/catchup/sessions//` ends the session. OpenAPI docs cover handshake and idle TTL behaviour. +- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux) + - **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom) + - **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `POST /api/catchup/sessions//position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions//` ends the session. OpenAPI docs cover handshake and idle TTL behaviour. - **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI. - - **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end. + - **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. When playback continues past the original programme into the catch-up buffer, the card advances to the next EPG programme. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end. - **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated). - **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure. - - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. + - **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order. - **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full. - **Per-client session pool.** The first request without `session_id` and no fingerprint match receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Reconnects that omit `session_id` but match an existing pool entry (same user, IP, and user-agent) are served immediately without a redirect, matching IPTV client fast-forward behaviour. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching. Plain GET restarts stream from byte 0 with upstream `Content-Length` when known (provider-faithful). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot. - - **`xmltv_prev_days_override` under `Settings → EPG`** (default `0` = auto-detect). Verbose timeshift logging follows the standard logger DEBUG level. - - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via setting or `?prev_days=` URL parameter. + - Verbose timeshift logging follows the standard logger DEBUG level. + - **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via per-user `epg_prev_days` or `?prev_days=` URL parameter. - **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer. - **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries. - **Combined connection stats API.** `GET /proxy/stats/` (admin) returns live, VOD, and catch-up connection stats in one JSON response (`live`, `vod`, `catchup`, `timestamp`). The Stats page polls this endpoint instead of separate live and VOD stats requests. - **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810) -- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings` and `EpgSettingsForm`. — Thanks [@nick4810](https://github.com/nick4810) +- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings`. — Thanks [@nick4810](https://github.com/nick4810) +- **Frontend unit tests extended to forms, modals, Connect, and Plugin Browse.** `OutputProfile`, `ServerGroup`, `CreateChannelModal`, `ProfileModal`, `Connect`, `ConnectLogs`, `PluginBrowse`, and `useEpgPreview` now have Vitest + Testing Library suites. Repo-management UI was extracted from `PluginBrowse` into `ManageReposModal`; form/schema helpers moved into `OutputProfileUtils` and `ServerGroupUtils`, with plugin-repo settings helpers added to `PluginsUtils`. — Thanks [@nick4810](https://github.com/nick4810) ### Changed @@ -42,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Swagger/OpenAPI schema generation no longer fails under concurrent gevent requests.** Concurrent hits to `/api/schema/` (for example Swagger UI loading) could race on DRF's shared `AutoSchema` state and raise `AssertionError: Schema generation REQUIRES a view instance`, leaving Swagger empty. Cold builds could also hang the gevent uWSGI worker. Schema introspection now runs in a real OS thread, builds are single-flight per process, and the result is cached in Django's cache (Redis) so all workers share it. - **`/proxy/ts/change_stream` now persists `stream_id` and waits for the real switch outcome in multi-worker deployments.** When the request landed on a worker that didn't own the channel, the owning worker's `STREAM_SWITCH` pubsub handler performed the switch but wrote only `url`/`user_agent` back to the channel's Redis metadata, so `GET /proxy/ts/status` kept reporting the old `stream_id` indefinitely; the handler now persists the full switch metadata (`stream_id`, `m3u_profile`, stream name) via the same `_update_channel_metadata()` helper the direct owner path uses. `stream_name` from the initial `get_stream_info_for_switch()` lookup is now forwarded through the pubsub payload (and from `change_stream` / `next_stream` on the direct owner path) so the owner no longer re-queries the database when writing metadata. The endpoint also no longer returns an optimistic 200 on the non-owner path: the requesting worker waits (up to 15s) for the owner to confirm via the `switch_status` Redis key and answers 502 if the owner reports failure or 504 if no confirmation arrives (e.g. owner worker gone); `next_stream` gets the same treatment. Requesting a switch to the URL the channel is already playing is now treated as success (with a metadata refresh) instead of a silent no-op, and the `switch_status` key now expires (60s TTL) instead of persisting forever. (Fixes #1412) - **XC live streams, `/panel_api.php`, and `/xmltv.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Authenticated XMLTV export (`/xmltv.php`, profile EPG with a logged-in user) had the same gap and could fail chunk-cache rebuilds with `KeyError` on `channel_num_map[channel.id]`. Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available integer; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there). - **System events and Connect dispatch no longer close the DB connection mid-call outside worker contexts.** `log_system_event()` and `dispatch_event_system()` always called `close_old_connections()` in `finally` blocks, which broke Django `TestCase` transactions and could interrupt in-flight ORM work when Connect/plugin handlers ran synchronously. Closes are now limited to gevent uWSGI workers (and the Celery sync-dispatch path where gevent is patched but no hub runs). Celery workers still reset connections via existing `task_postrun` / `task_prerun` hooks on the standard PostgreSQL backend (no geventpool). diff --git a/apps/api/schema_views.py b/apps/api/schema_views.py new file mode 100644 index 00000000..3aef9ec4 --- /dev/null +++ b/apps/api/schema_views.py @@ -0,0 +1,200 @@ +"""OpenAPI schema views that are safe under gevent concurrency. + +DRF's AutoSchema descriptor keeps mutable per-class state (``self.view``). +When uWSGI serves requests with gevent, concurrent ``/api/schema/`` builds +interleave on that state and raise: + + AssertionError: Schema generation REQUIRES a view instance + +Cold builds also frequently hang the gevent hub when introspection runs in a +greenlet. Generation therefore runs in a real OS thread (gevent ThreadPool), +with per-process single-flight coordination and a Django/Redis cache shared +by all workers. +""" + +from __future__ import annotations + +import copy +import logging +import threading +from typing import Callable, Dict, Optional + +from django.conf import settings +from django.core.cache import cache +from rest_framework.response import Response + +from drf_spectacular.views import SpectacularAPIView + +logger = logging.getLogger(__name__) + +try: + # Real OS threads; .get() waits without blocking the gevent hub. + from gevent import monkey + from gevent.threadpool import ThreadPool + + _SCHEMA_POOL: Optional[ThreadPool] = ThreadPool(maxsize=1) + _GEVENT_PATCHED = monkey.is_module_patched("threading") +except ImportError: # pragma: no cover - non-gevent runtimes + _SCHEMA_POOL = None + _GEVENT_PATCHED = False + +# Greenlet-safe after gevent monkey-patching. +_guard = threading.Lock() +_in_progress: Dict[str, threading.Event] = {} +_flight_results: Dict[str, dict] = {} + +_SCHEMA_CACHE_PREFIX = "openapi:schema:" +_SCHEMA_CACHE_VER_KEY = f"{_SCHEMA_CACHE_PREFIX}cache_ver" +_BUILD_TIMEOUT_SECONDS = 120 + + +def _run_schema_build(build: Callable[[], dict]) -> dict: + """Run schema introspection off the gevent hub when monkey-patched.""" + if _SCHEMA_POOL is not None and _GEVENT_PATCHED: + return _SCHEMA_POOL.spawn(build).get(timeout=_BUILD_TIMEOUT_SECONDS) + return build() + + +def _cache_supports_delete_pattern() -> bool: + return callable(getattr(cache, "delete_pattern", None)) + + +def clear_schema_cache() -> None: + """Invalidate cached schemas (tests / forced refresh after deploy).""" + with _guard: + _flight_results.clear() + try: + if _cache_supports_delete_pattern(): + cache.delete_pattern(f"{_SCHEMA_CACHE_PREFIX}*") + return + ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 0 + cache.set(_SCHEMA_CACHE_VER_KEY, int(ver) + 1, timeout=None) + except Exception: + logger.warning("Failed to clear OpenAPI schema cache", exc_info=True) + + +def _key_namespace() -> str: + """Stable on Redis (pattern delete); versioned on LocMem for test clears.""" + if _cache_supports_delete_pattern(): + return "shared" + try: + ver = cache.get(_SCHEMA_CACHE_VER_KEY) + if ver is None: + cache.add(_SCHEMA_CACHE_VER_KEY, 1, timeout=None) + ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 1 + return f"v{int(ver)}" + except Exception: + return "v1" + + +class LockedSpectacularAPIView(SpectacularAPIView): + """SpectacularAPIView with single-flight generation and Django cache.""" + + def _resolve_version(self, request): + return self.api_version or request.version or self._get_version_parameter( + request + ) + + def _cache_key(self, request) -> str: + version = self._resolve_version(request) + lang = request.GET.get("lang") if settings.USE_I18N else None + urlconf = self.urlconf + if urlconf is None: + urlconf_part = "default" + else: + urlconf_part = getattr(urlconf, "__name__", None) or type(urlconf).__name__ + patterns_part = "custom" if self.patterns is not None else "default" + return ( + f"{_SCHEMA_CACHE_PREFIX}{_key_namespace()}:" + f"{version}:{lang}:{self.serve_public}:{urlconf_part}:{patterns_part}" + ) + + def _cache_get(self, key: str) -> Optional[dict]: + try: + return cache.get(key) + except Exception: + logger.warning("OpenAPI schema cache get failed", exc_info=True) + return None + + def _cache_set(self, key: str, schema: dict) -> None: + try: + # Use CACHES['default'] TIMEOUT (3600s in settings). + cache.set(key, schema) + except Exception: + logger.warning("OpenAPI schema cache set failed", exc_info=True) + + def _build_schema(self, request, version: Optional[str]) -> dict: + generator = self.generator_class( + urlconf=self.urlconf, + api_version=version, + patterns=self.patterns, + ) + return generator.get_schema(request=request, public=self.serve_public) + + def _get_schema_response(self, request): + version = self._resolve_version(request) + cache_key = self._cache_key(request) + + cached = self._cache_get(cache_key) + if cached is not None: + return self._schema_response(request, version, cached) + + with _guard: + cached = self._cache_get(cache_key) + if cached is not None: + return self._schema_response(request, version, cached) + + event = _in_progress.get(cache_key) + if event is None: + event = threading.Event() + _in_progress[cache_key] = event + is_builder = True + else: + is_builder = False + + if not is_builder: + if not event.wait(timeout=_BUILD_TIMEOUT_SECONDS): + return Response( + {"detail": "Timed out waiting for OpenAPI schema generation."}, + status=503, + ) + cached = self._cache_get(cache_key) + if cached is None: + with _guard: + cached = _flight_results.get(cache_key) + if cached is None: + return Response( + {"detail": "OpenAPI schema generation failed."}, + status=500, + ) + return self._schema_response(request, version, cached) + + try: + schema = _run_schema_build( + lambda: self._build_schema(request, version) + ) + with _guard: + _flight_results[cache_key] = schema + self._cache_set(cache_key, schema) + return self._schema_response(request, version, schema) + except Exception: + logger.exception("OpenAPI schema generation failed") + return Response( + {"detail": "OpenAPI schema generation failed."}, + status=500, + ) + finally: + with _guard: + _in_progress.pop(cache_key, None) + event.set() + + def _schema_response(self, request, version, schema: dict) -> Response: + # LocMem returns the same object; deepcopy keeps responses isolated. + return Response( + data=copy.deepcopy(schema), + headers={ + "Content-Disposition": ( + f'inline; filename="{self._get_filename(request, version)}"' + ) + }, + ) diff --git a/apps/api/urls.py b/apps/api/urls.py index 5af829d3..200dc0a6 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,5 +1,7 @@ from django.urls import path, include, re_path -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView +from drf_spectacular.views import SpectacularSwaggerView, SpectacularRedocView + +from apps.api.schema_views import LockedSpectacularAPIView app_name = 'api' @@ -22,9 +24,9 @@ urlpatterns = [ - # OpenAPI Schema and Documentation (drf-spectacular) - path('schema/', SpectacularAPIView.as_view(), name='schema'), + # OpenAPI schema (single-flight + Django cache; see apps.api.schema_views) + path('schema/', LockedSpectacularAPIView.as_view(), name='schema'), re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), - path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), + path('swagger.json', LockedSpectacularAPIView.as_view(), name='schema-json'), ] diff --git a/apps/channels/tests/test_catchup_utils.py b/apps/channels/tests/test_catchup_utils.py index 1020aa65..31ea7e02 100644 --- a/apps/channels/tests/test_catchup_utils.py +++ b/apps/channels/tests/test_catchup_utils.py @@ -44,13 +44,6 @@ class ResolveXcEpgPrevDaysTests(TestCase): self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3) mock_compute.assert_not_called() - @patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14) - @patch("core.models.CoreSettings.get_xmltv_prev_days_override", return_value=7) - def test_epg_settings_override_prevents_auto_detect(self, _override, mock_compute): - request = self.factory.get("/xmltv.php") - self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 7) - mock_compute.assert_not_called() - class CatchupRollupActiveAccountTests(TestCase): """Denormalized catch-up flags ignore disabled M3U accounts.""" diff --git a/apps/channels/utils.py b/apps/channels/utils.py index 70b9eecd..e5f9b08c 100644 --- a/apps/channels/utils.py +++ b/apps/channels/utils.py @@ -67,8 +67,7 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True): Resolution order: 1. URL ``?prev_days=`` (explicit; 0 means no past programmes) 2. ``user.custom_properties.epg_prev_days`` - 3. ``CoreSettings.epg_settings.xmltv_prev_days_override`` when > 0 - 4. Auto-detect (only when *auto_detect_fallback* is True) + 3. Auto-detect (only when *auto_detect_fallback* is True) """ user_custom = (user.custom_properties or {}) if user else {} url_prev = request.GET.get("prev_days") @@ -85,14 +84,6 @@ def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True): except (ValueError, TypeError): return 0 - from core.models import CoreSettings - - try: - override = int(CoreSettings.get_xmltv_prev_days_override() or 0) - except (TypeError, ValueError): - override = 0 - if override > 0: - return max(0, min(override, MAX_AUTO_PREV_DAYS)) if auto_detect_fallback: return compute_provider_archive_days_capped() return 0 diff --git a/apps/timeshift/api_urls.py b/apps/timeshift/api_urls.py index afcecddb..1f93fb16 100644 --- a/apps/timeshift/api_urls.py +++ b/apps/timeshift/api_urls.py @@ -10,6 +10,11 @@ urlpatterns = [ api_views.CatchupSessionCreateAPIView.as_view(), name="catchup-session-create", ), + path( + "sessions//position/", + api_views.CatchupSessionPositionAPIView.as_view(), + name="catchup-session-position", + ), path( "sessions//", api_views.CatchupSessionDestroyAPIView.as_view(), diff --git a/apps/timeshift/api_views.py b/apps/timeshift/api_views.py index 59954a30..7e216df8 100644 --- a/apps/timeshift/api_views.py +++ b/apps/timeshift/api_views.py @@ -9,9 +9,10 @@ from rest_framework.views import APIView from apps.accounts.permissions import IsStandardUser from apps.channels.models import Channel from apps.channels.utils import get_channel_catchup_streams +from core.utils import RedisClient from dispatcharr.utils import network_access_allowed -from .helpers import parse_catchup_timestamp +from .helpers import MAX_DURATION_MINUTES, parse_catchup_timestamp from .sessions import ( HANDSHAKE_TTL_SECONDS, SESSION_IDLE_TTL_SECONDS, @@ -19,7 +20,11 @@ from .sessions import ( delete_catchup_session, user_owns_catchup_session, ) -from .views import _user_can_access_channel +from .stats import update_catchup_session_position +from .views import _trigger_timeshift_stats_update, _user_can_access_channel + +# Programme length cap expressed in seconds for position reports. +_MAX_POSITION_SECS = MAX_DURATION_MINUTES * 60 class CatchupSessionCreateSerializer(serializers.Serializer): @@ -32,6 +37,16 @@ class CatchupSessionCreateSerializer(serializers.Serializer): "ISO-8601 (2026-07-09T14:00:00Z), Unix epoch, or XC wall-clock shapes." ), ) + duration = serializers.IntegerField( + required=False, + min_value=1, + max_value=MAX_DURATION_MINUTES, + help_text=( + "Optional programme length in minutes. Preferred over EPG when " + "supplied. A short buffer is added for provider archive lag. " + "Omit to derive the length from EPG." + ), + ) class CatchupSessionResponseSerializer(serializers.Serializer): @@ -42,6 +57,11 @@ class CatchupSessionResponseSerializer(serializers.Serializer): ) channel_uuid = serializers.UUIDField() start = serializers.CharField() + duration = serializers.IntegerField( + required=False, + allow_null=True, + help_text="Programme length in minutes if supplied at creation, else null.", + ) class CatchupSessionCreateAPIView(APIView): @@ -58,6 +78,11 @@ class CatchupSessionCreateAPIView(APIView): "**``start``** is the programme's broadcast start time in UTC " "(from EPG ``start_time``). It selects *which* archived show to " "fetch, not when the viewer pressed play.\n\n" + "**``duration``** is optional programme length in minutes. Native " + "clients should send it when their guide knows the programme " + "length. Dispatcharr uses it before local EPG duration, adds a " + "short provider-lag buffer, and falls back to EPG duration, then " + "the default archive window when omitted.\n\n" f"The player should open ``playback_url`` within " f"**{HANDSHAKE_TTL_SECONDS} seconds** (see ``expires_at``). After the " "first byte request, the session stays valid with a " @@ -120,7 +145,12 @@ class CatchupSessionCreateAPIView(APIView): ) try: - payload = create_catchup_session(user=user, channel=channel, start=start) + payload = create_catchup_session( + user=user, + channel=channel, + start=start, + duration=body.validated_data.get("duration"), + ) except RuntimeError: return Response( {"error": "Session service unavailable"}, @@ -166,3 +196,79 @@ class CatchupSessionDestroyAPIView(APIView): delete_catchup_session(session_id) return Response(status=status.HTTP_204_NO_CONTENT) + + +class CatchupSessionPositionSerializer(serializers.Serializer): + position_secs = serializers.FloatField( + min_value=0, + max_value=_MAX_POSITION_SECS, + help_text=( + "Current playhead within the programme, in seconds from programme start." + ), + ) + paused = serializers.BooleanField( + required=False, + help_text=( + "When true, admin stats freeze at ``position_secs`` (no wall-clock " + "advance). When false, clear pause. Omit to leave pause state unchanged." + ), + ) + + +class CatchupSessionPositionAPIView(APIView): + """Native clients report local playhead / pause for catch-up stats polish.""" + + permission_classes = [IsStandardUser] + + @extend_schema( + description=( + "Update the reported playhead for an active catch-up session.\n\n" + "Native apps can call this while paused or after local scrubbing so " + "admin stats stay aligned with what the viewer sees. This does " + "**not** seek the provider stream; HTTP ``Range`` / a new archive " + "open still control bytes.\n\n" + "Requires an active playback connection for ``session_id``. Also " + "refreshes the session idle TTL." + ), + request=CatchupSessionPositionSerializer, + responses={ + 204: None, + 400: inline_serializer( + name="CatchupSessionPositionError", + fields={"error": serializers.CharField()}, + ), + 403: inline_serializer( + name="CatchupSessionPositionForbidden", + fields={"error": serializers.CharField()}, + ), + 404: inline_serializer( + name="CatchupSessionPositionNotFound", + fields={"error": serializers.CharField()}, + ), + }, + tags=["catchup"], + ) + def post(self, request, session_id): + if not network_access_allowed(request, "STREAMS", request.user): + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + if not user_owns_catchup_session(session_id, request.user.id): + return Response({"error": "Session not found"}, status=status.HTTP_404_NOT_FOUND) + + body = CatchupSessionPositionSerializer(data=request.data) + body.is_valid(raise_exception=True) + + updated = update_catchup_session_position( + session_id, + position_secs=body.validated_data["position_secs"], + paused=body.validated_data.get("paused"), + user_id=request.user.id, + ) + if not updated: + return Response( + {"error": "No active playback for this session"}, + status=status.HTTP_404_NOT_FOUND, + ) + + _trigger_timeshift_stats_update(RedisClient.get_client()) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/timeshift/helpers.py b/apps/timeshift/helpers.py index 7b8d5ac1..adc80861 100644 --- a/apps/timeshift/helpers.py +++ b/apps/timeshift/helpers.py @@ -1,12 +1,16 @@ """URL builders and timestamp helpers for XC catch-up.""" import logging +import math import re +import time from collections import namedtuple -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from urllib.parse import quote from zoneinfo import ZoneInfo +from apps.timeshift.redis_keys import TimeshiftRedisKeys + logger = logging.getLogger(__name__) # Credentials for the profile whose pool slot was reserved (not raw account fields). @@ -15,10 +19,13 @@ TimeshiftCredentials = namedtuple( ) DEFAULT_DURATION_MINUTES = 120 +# Extra minutes added to client/EPG programme length when asking the provider. +# IPTV archives commonly lag live by about 30 seconds to 2 minutes, so a bare +# programme length tends to include the previous show's tail and clip the end. DURATION_BUFFER_MINUTES = 5 MAX_DURATION_MINUTES = 480 -# Wall-clock shapes seen from XC / iPlayTV / TiviMate clients. Compiled once. +# Wall-clock shapes seen from XC catch-up clients. Compiled once. _CATCHUP_WALL_CLOCK_RE = re.compile( r"^" r"(?P\d{4}-\d{2}-\d{2})" @@ -38,7 +45,7 @@ def normalize_catchup_timestamp_input(timestamp_str): """Map a client catch-up timestamp to an ISO-8601 string for ``fromisoformat``. Supported inputs: - - ``YYYY-MM-DD:HH-MM`` (iPlayTV/TiviMate colon-dash) + - ``YYYY-MM-DD:HH-MM`` (XC colon-dash) - ``YYYY-MM-DD_HH-MM`` (XC underscore) - ``YYYY-MM-DD:HH:MM[:SS]`` (XC colon time in catch-up URLs) - ``YYYY-MM-DD HH:MM[:SS]`` (EPG / SQL datetime) @@ -158,8 +165,9 @@ def get_programme_duration(channel, timestamp_str): timestamp_str: Programme start in UTC (same shape as the client URL). Returns: - Programme length plus a small buffer, capped at ``MAX_DURATION_MINUTES``, - or ``DEFAULT_DURATION_MINUTES`` when EPG lookup fails. + Programme length plus ``DURATION_BUFFER_MINUTES`` (provider archive lag), + capped at ``MAX_DURATION_MINUTES``, or ``DEFAULT_DURATION_MINUTES`` when + EPG lookup fails. """ try: dt = parse_catchup_timestamp(timestamp_str) @@ -183,8 +191,54 @@ def get_programme_duration(channel, timestamp_str): return DEFAULT_DURATION_MINUTES -def get_programme_info(channel, timestamp_str): - """Return EPG metadata for the programme airing at *timestamp_str*.""" +def client_duration_to_window(value): + """Convert a client-supplied programme length (minutes) to an archive window. + + Args: + value: Raw client hint (str/int). PATH XC duration segment, QUERY + ``duration=``, or the native session's stored ``duration``. + + Returns: + Minutes to request from the provider (client length + + ``DURATION_BUFFER_MINUTES``, capped), or ``None`` when the hint is + missing or not a usable positive integer. + + The buffer matches the EPG path: clients usually send exact guide length + (for example ``.../60/.../123.ts`` for a 60-minute show), but provider + archives lag live, so requesting that bare length clips the end. + """ + if value is None: + return None + try: + minutes = int(str(value).strip()) + except (TypeError, ValueError): + return None + if minutes <= 0: + return None + return min(minutes + DURATION_BUFFER_MINUTES, MAX_DURATION_MINUTES) + + +def resolve_catchup_duration(channel, timestamp_str, client_hint=None): + """Pick the catch-up archive window in minutes. + + Preference order: a sane client-supplied hint, then the EPG programme + length, then ``DEFAULT_DURATION_MINUTES``. + """ + window = client_duration_to_window(client_hint) + if window is not None: + return window + return get_programme_duration(channel, timestamp_str) + + +def get_programme_info(channel, timestamp_str, position_secs=None): + """Return EPG metadata for the programme airing at *timestamp_str*. + + When ``position_secs`` is set (playhead within the archive, relative to the + programme that contains ``timestamp_str``), and that playhead is at or past + the programme's end, resolve the guide entry at the playhead instead. That + keeps catch-up stats cards on the show the viewer has actually reached when + they keep watching into the provider buffer / next programme. + """ try: dt = parse_catchup_timestamp(timestamp_str) if dt is None: @@ -199,6 +253,19 @@ def get_programme_info(channel, timestamp_str): if not programme: return None + if position_secs is not None: + try: + offset = max(0.0, float(position_secs)) + except (TypeError, ValueError): + offset = 0.0 + playhead = programme.start_time + timedelta(seconds=offset) + if playhead >= programme.end_time: + advanced = channel.epg_data.programs.filter( + start_time__lte=playhead, end_time__gt=playhead + ).first() + if advanced is not None: + programme = advanced + duration_seconds = (programme.end_time - programme.start_time).total_seconds() return { "title": programme.title, @@ -216,10 +283,19 @@ def get_catchup_programmes_for_sessions(sessions): """Resolve EPG metadata for catch-up stats cards (batch, on demand). Each session dict needs ``session_id``, ``channel_uuid``, and - ``programme_start``. Called by the frontend when active sessions or seek - position change, not on every stats poll. + ``programme_start``. Optional ``position_secs`` (playhead within the + archive, relative to the programme containing ``programme_start``) + advances the returned programme when past that show's end. When omitted, + an estimate is taken from the session's Redis stats metadata when present. """ from apps.channels.models import Channel + from apps.timeshift.stats import ( + _client_paused, + _decode_hash, + compute_playback_position_secs, + find_stats_channel_for_session, + ) + from core.utils import RedisClient if not sessions: return [] @@ -238,12 +314,40 @@ def get_catchup_programmes_for_sessions(sessions): for ch in Channel.objects.filter(uuid__in=uuids).select_related("epg_data") } + redis_client = RedisClient.get_client() results = [] for session in valid: channel_uuid = str(session["channel_uuid"]) programme_start = session["programme_start"] channel = channels_by_uuid.get(channel_uuid) + position_secs = session.get("position_secs") + if position_secs is not None: + try: + position_secs = float(position_secs) + except (TypeError, ValueError): + position_secs = None + + # Resolve the guide entry for the URL first so Redis playhead math has + # an EPG start; then re-resolve with position to advance past the end. info = get_programme_info(channel, programme_start) if channel else None + if position_secs is None and info is not None and redis_client is not None: + position_secs = _position_secs_from_stats( + redis_client, + session_id=session["session_id"], + programme_start=programme_start, + epg_start_iso=info["start_time"], + compute_playback_position_secs=compute_playback_position_secs, + find_stats_channel_for_session=find_stats_channel_for_session, + client_paused=_client_paused, + decode_hash=_decode_hash, + ) + if channel is not None and position_secs is not None: + advanced = get_programme_info( + channel, programme_start, position_secs=position_secs, + ) + if advanced is not None: + info = advanced + entry = { "session_id": session["session_id"], "channel_uuid": channel_uuid, @@ -262,6 +366,46 @@ def get_catchup_programmes_for_sessions(sessions): return results +def _position_secs_from_stats( + redis_client, + *, + session_id, + programme_start, + epg_start_iso, + compute_playback_position_secs, + find_stats_channel_for_session, + client_paused, + decode_hash, +): + """Best-effort uncapped playhead from timeshift stats Redis metadata.""" + try: + stats_channel_id = find_stats_channel_for_session(redis_client, session_id) + if not stats_channel_id: + return None + client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id) + client_data = decode_hash(redis_client.hgetall(client_key)) + if not client_data: + return None + playback_base_raw = client_data.get("playback_base_secs") + playback_base_secs = None + if playback_base_raw not in (None, ""): + try: + playback_base_secs = float(playback_base_raw) + except (TypeError, ValueError): + playback_base_secs = None + return compute_playback_position_secs( + programme_start, + epg_start_iso, + client_data.get("position_anchor_at"), + time.time(), + duration_secs=None, + playback_base_secs=playback_base_secs, + paused=client_paused(client_data.get("paused")), + ) + except Exception: + return None + + def build_timeshift_url_format_a(creds, stream_id, timestamp, duration_minutes): """QUERY layout: ``/streaming/timeshift.php?username=...&start=...``""" return ( @@ -339,3 +483,51 @@ def format_timestamp_as_underscore(timestamp): def format_timestamp_as_sql_datetime(timestamp): """Reshape to ``YYYY-MM-DD HH:MM:SS`` without timezone conversion.""" return _reshape_timestamp(timestamp, "%Y-%m-%d %H:%M:%S", "SQL") + + +def programme_age_days(timestamp_str, *, now=None): + """Archive depth in whole days needed to cover a catch-up start timestamp. + + Returns: + ``None`` if the timestamp cannot be parsed, ``0`` if start is at/after + *now*, otherwise ``ceil(elapsed / 86400)`` (at least 1). + """ + dt = parse_catchup_timestamp(timestamp_str) + if dt is None: + return None + if now is None: + now = datetime.now(timezone.utc).replace(tzinfo=None) + elif getattr(now, "tzinfo", None) is not None: + now = now.astimezone(timezone.utc).replace(tzinfo=None) + + elapsed = (now - dt).total_seconds() + if elapsed <= 0: + return 0 + return max(1, math.ceil(elapsed / 86400.0)) + + +def order_catchup_streams_for_timestamp(streams, timestamp_str, *, now=None): + """Prefer streams whose ``catchup_days`` cover the programme age. + + Relative channel order is preserved within the preferred and fallback + groups. Streams with unknown/zero ``catchup_days`` stay preferred so + incomplete provider metadata does not skip a possible server. Unparseable + timestamps leave the input order unchanged. + """ + age = programme_age_days(timestamp_str, now=now) + if age is None: + return list(streams) + + preferred = [] + fallback = [] + for stream in streams: + raw = getattr(stream, "catchup_days", None) + try: + days = int(raw) if raw is not None else 0 + except (TypeError, ValueError): + days = 0 + if days <= 0 or days >= age: + preferred.append(stream) + else: + fallback.append(stream) + return preferred + fallback diff --git a/apps/timeshift/sessions.py b/apps/timeshift/sessions.py index 1836e29e..b97e2add 100644 --- a/apps/timeshift/sessions.py +++ b/apps/timeshift/sessions.py @@ -38,8 +38,12 @@ def mint_catchup_session_id(): return mint_session_id() -def create_catchup_session(*, user, channel, start): - """Persist a new playback session and return metadata for the API response.""" +def create_catchup_session(*, user, channel, start, duration=None): + """Persist a new playback session and return metadata for the API response. + + ``duration`` is an optional programme length in minutes. When supplied it is + preferred over EPG at playback time (see ``resolve_catchup_duration``). + """ redis_client = RedisClient.get_client() if redis_client is None: raise RuntimeError("Redis unavailable") @@ -47,16 +51,16 @@ def create_catchup_session(*, user, channel, start): session_id = mint_session_id() now = int(time.time()) key = TimeshiftRedisKeys.api_session(session_id) - redis_client.hset( - key, - mapping={ - "user_id": str(user.id), - "channel_uuid": str(channel.uuid), - "channel_id": str(channel.id), - "start": str(start), - "created_at": str(now), - }, - ) + mapping = { + "user_id": str(user.id), + "channel_uuid": str(channel.uuid), + "channel_id": str(channel.id), + "start": str(start), + "created_at": str(now), + } + if duration is not None: + mapping["duration"] = str(duration) + redis_client.hset(key, mapping=mapping) redis_client.expire(key, HANDSHAKE_TTL_SECONDS) handshake_expires_at = now + HANDSHAKE_TTL_SECONDS @@ -68,6 +72,7 @@ def create_catchup_session(*, user, channel, start): "expires_at": handshake_expires_at, "channel_uuid": str(channel.uuid), "start": str(start), + "duration": duration, } @@ -157,8 +162,9 @@ def resolve_catchup_playback(session_id, channel_uuid): """Resolve user and programme start for a tokenless playback request. Returns: - ``(user, start)`` on success, or ``None`` if the session is invalid, - expired, or bound to a different channel. + ``(user, start, duration)`` on success, or ``None`` if the session is + invalid, expired, or bound to a different channel. ``duration`` is the + stored client programme length in minutes, or ``None`` when unset. """ record = get_catchup_session(session_id) if not record: @@ -184,7 +190,7 @@ def resolve_catchup_playback(session_id, channel_uuid): if not start: return None - return user, str(start) + return user, str(start), record.get("duration") def user_owns_catchup_session(session_id, user_id): diff --git a/apps/timeshift/stats.py b/apps/timeshift/stats.py index c7580cba..ff4e3066 100644 --- a/apps/timeshift/stats.py +++ b/apps/timeshift/stats.py @@ -13,8 +13,13 @@ from apps.m3u.models import M3UAccountProfile from apps.proxy.live_proxy.constants import ChannelMetadataField from apps.timeshift.redis_keys import TimeshiftRedisKeys, parse_stats_channel_id from apps.timeshift.helpers import parse_catchup_timestamp +from core.utils import RedisClient logger = logging.getLogger(__name__) + +# Shared with views near-EOF classification (common ~1.88MB duration probes). +EOF_PROBE_TAIL_BYTES = 2_097_152 + _STREAM_STATS_TO_METADATA = { "video_codec": ChannelMetadataField.VIDEO_CODEC, "resolution": ChannelMetadataField.RESOLUTION, @@ -165,6 +170,32 @@ def resolve_stats_playback_fields( existing_programme_start is not None and existing_programme_start != timestamp_utc ) + + # Near-EOF duration probes must not reanchor stats to end-of-file. + if ( + range_start is not None + and representation_length is not None + and not programme_changed + ): + try: + start = int(range_start) + total = int(representation_length) + except (TypeError, ValueError): + start = None + total = None + if start is not None and total is not None and total > 0: + if start >= max(0, total - EOF_PROBE_TAIL_BYTES): + try: + keep_base = ( + float(existing_playback_base) + if existing_playback_base is not None + else None + ) + except (TypeError, ValueError): + keep_base = None + keep_anchor = existing_position_anchor or now + return keep_base, keep_anchor + byte_base = compute_playback_base_from_byte_range( range_start, representation_length, programme_duration_secs, ) @@ -192,6 +223,7 @@ def compute_playback_position_secs( current_time, duration_secs=None, playback_base_secs=None, + paused=False, ): """Best-effort catch-up play position in seconds within the programme. @@ -203,9 +235,12 @@ def compute_playback_position_secs( Native players (VLC) often keep the programme URL fixed and seek via ``Range: bytes=…``; in that case ``playback_base_secs`` carries the mapped position at the anchor instead of the URL timestamp offset. + + When ``paused`` is true, wall-clock since the anchor is ignored so admin + stats stay frozen at the last reported playhead. """ elapsed_since_anchor = 0.0 - if position_anchor_at: + if not paused and position_anchor_at: try: elapsed_since_anchor = max(0.0, current_time - float(position_anchor_at)) except (TypeError, ValueError): @@ -258,6 +293,81 @@ def find_stats_channel_for_session(redis_client, session_id): return None +def _client_paused(raw_value): + if raw_value is None or raw_value == "": + return False + value = _decode_value(raw_value).strip().lower() + return value in {"1", "true", "yes"} + + +def update_catchup_session_position( + session_id, + *, + position_secs, + paused=None, + user_id=None, + redis_client=None, +): + """Record a native client's playhead for catch-up stats. + + Updates the active stats client hash for ``session_id`` and refreshes the + API session idle TTL. Does not seek the provider stream. + + Returns: + ``True`` when metadata was updated, ``False`` when there is no active + playback stats entry (or Redis is unavailable). + """ + from apps.timeshift.sessions import touch_catchup_session + + if redis_client is None: + redis_client = RedisClient.get_client() + if redis_client is None or not session_id: + return False + + stats_channel_id = find_stats_channel_for_session(redis_client, session_id) + if not stats_channel_id: + return False + + client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id) + client_set_key = TimeshiftRedisKeys.clients(stats_channel_id) + metadata_key = TimeshiftRedisKeys.channel_metadata(stats_channel_id) + try: + if not redis_client.exists(client_key): + return False + if user_id is not None: + stored_user = redis_client.hget(client_key, "user_id") + if stored_user is not None and str(_decode_value(stored_user)) != str(user_id): + return False + except Exception: + return False + + now = str(time.time()) + mapping = { + "playback_base_secs": str(float(position_secs)), + "position_anchor_at": now, + "last_active": now, + } + # Must match apps.timeshift.views.CLIENT_TTL_SECONDS (stats / fingerprint). + client_ttl_seconds = 60 + + try: + pipe = redis_client.pipeline(transaction=False) + pipe.hset(client_key, mapping=mapping) + if paused is True: + pipe.hset(client_key, "paused", "1") + elif paused is False: + pipe.hdel(client_key, "paused") + pipe.expire(client_key, client_ttl_seconds) + pipe.expire(client_set_key, client_ttl_seconds) + pipe.expire(metadata_key, client_ttl_seconds) + pipe.execute() + except Exception: + return False + + touch_catchup_session(session_id, redis_client=redis_client) + return True + + def build_timeshift_stats_data(redis_client): """Build catch-up stats payload from Redis session metadata.""" empty = { @@ -339,6 +449,8 @@ def build_timeshift_stats_data(redis_client): except (TypeError, ValueError): playback_base_secs = None + paused = _client_paused(client_data.get("paused")) + connected_at = client_data.get("connected_at") duration = 0 if connected_at: @@ -358,6 +470,7 @@ def build_timeshift_stats_data(redis_client): "programme_start": programme_start, "position_anchor_at": client_data.get("position_anchor_at"), "playback_base_secs": playback_base_secs, + "paused": paused, "m3u_profile_id": int(m3u_profile_id) if m3u_profile_id else None, "ip_address": client_data.get("ip_address", "Unknown"), "user_agent": client_data.get("user_agent", "unknown"), @@ -437,6 +550,7 @@ def build_timeshift_stats_data(redis_client): "programme_start": conn["programme_start"], "position_anchor_at": position_anchor_at, "playback_base_secs": playback_base_secs, + "paused": bool(conn.get("paused")), "resolution": conn.get("resolution"), "source_fps": conn.get("source_fps"), "video_codec": conn.get("video_codec"), diff --git a/apps/timeshift/tests/test_helpers.py b/apps/timeshift/tests/test_helpers.py index 068bf1d0..a9647df3 100644 --- a/apps/timeshift/tests/test_helpers.py +++ b/apps/timeshift/tests/test_helpers.py @@ -15,13 +15,15 @@ from apps.timeshift.helpers import ( format_timestamp_as_sql_datetime, format_timestamp_as_underscore, normalize_catchup_timestamp_input, + order_catchup_streams_for_timestamp, parse_catchup_timestamp, + programme_age_days, ) def _make_creds(): # The builders consume resolved per-profile credentials, never an account - # object — get_transformed_credentials() produces these in the view. + # object - get_transformed_credentials() produces these in the view. return TimeshiftCredentials("http://example.test", "user", "pass") @@ -335,3 +337,116 @@ class GetProgrammeDurationTests(TestCase): from unittest.mock import MagicMock from apps.timeshift.helpers import get_programme_duration self.assertEqual(get_programme_duration(MagicMock(), "garbage"), 120) + + +class ClientDurationTests(TestCase): + """Client-supplied programme length: sanitised, buffered for provider lag, + capped, and preferred over EPG when usable.""" + + def test_valid_hint_gets_buffer(self): + from apps.timeshift.helpers import client_duration_to_window + self.assertEqual(client_duration_to_window(30), 35) + self.assertEqual(client_duration_to_window("30"), 35) + + def test_hint_capped_at_max(self): + from apps.timeshift.helpers import client_duration_to_window + self.assertEqual(client_duration_to_window(1000), 480) + + def test_unusable_hint_returns_none(self): + from apps.timeshift.helpers import client_duration_to_window + for bad in (None, "", "abc", "0", "-5", 0, -10): + self.assertIsNone(client_duration_to_window(bad)) + + def test_resolve_prefers_client_hint(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import resolve_catchup_duration + # EPG would say 120 (no programme), but a valid hint wins. + channel = MagicMock(epg_data=None) + self.assertEqual( + resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint="30"), + 35, + ) + + def test_resolve_falls_back_to_epg_when_hint_missing(self): + from unittest.mock import MagicMock + from apps.timeshift.helpers import resolve_catchup_duration + channel = MagicMock(epg_data=None) + self.assertEqual( + resolve_catchup_duration(channel, "2026-06-08:17-00", client_hint=None), + 120, + ) + + +class ProgrammeAgeAndStreamOrderTests(TestCase): + """Archive-age helpers used to prefer deep catch-up providers first.""" + + def test_programme_age_days_ceil(self): + now = datetime(2026, 7, 16, 12, 0, 0) + self.assertEqual( + programme_age_days("2026-07-12:12-00", now=now), + 4, + ) + self.assertEqual( + programme_age_days("2026-07-15:12-00", now=now), + 1, + ) + self.assertEqual( + programme_age_days("2026-07-16:12-00", now=now), + 0, + ) + + def test_programme_age_days_unparseable(self): + self.assertIsNone(programme_age_days("garbage")) + + def test_order_prefers_covering_streams_then_fallback(self): + from types import SimpleNamespace + + now = datetime(2026, 7, 16, 12, 0, 0) + streams = [ + SimpleNamespace(catchup_days=2, name="p1"), + SimpleNamespace(catchup_days=1, name="p2"), + SimpleNamespace(catchup_days=5, name="p3"), + ] + ordered = order_catchup_streams_for_timestamp( + streams, "2026-07-12:12-00", now=now + ) + self.assertEqual([s.name for s in ordered], ["p3", "p1", "p2"]) + + def test_order_keeps_channel_order_within_groups(self): + from types import SimpleNamespace + + now = datetime(2026, 7, 16, 12, 0, 0) + streams = [ + SimpleNamespace(catchup_days=7, name="a"), + SimpleNamespace(catchup_days=2, name="b"), + SimpleNamespace(catchup_days=14, name="c"), + SimpleNamespace(catchup_days=1, name="d"), + ] + ordered = order_catchup_streams_for_timestamp( + streams, "2026-07-12:12-00", now=now + ) + self.assertEqual([s.name for s in ordered], ["a", "c", "b", "d"]) + + def test_unknown_catchup_days_stay_preferred(self): + from types import SimpleNamespace + + now = datetime(2026, 7, 16, 12, 0, 0) + streams = [ + SimpleNamespace(catchup_days=2, name="short"), + SimpleNamespace(catchup_days=0, name="unknown"), + SimpleNamespace(catchup_days=5, name="deep"), + ] + ordered = order_catchup_streams_for_timestamp( + streams, "2026-07-12:12-00", now=now + ) + self.assertEqual([s.name for s in ordered], ["unknown", "deep", "short"]) + + def test_unparseable_timestamp_preserves_order(self): + from types import SimpleNamespace + + streams = [ + SimpleNamespace(catchup_days=2, name="a"), + SimpleNamespace(catchup_days=5, name="b"), + ] + ordered = order_catchup_streams_for_timestamp(streams, "garbage") + self.assertEqual([s.name for s in ordered], ["a", "b"]) diff --git a/apps/timeshift/tests/test_sessions.py b/apps/timeshift/tests/test_sessions.py index 0a6e8060..5d815bc0 100644 --- a/apps/timeshift/tests/test_sessions.py +++ b/apps/timeshift/tests/test_sessions.py @@ -126,6 +126,38 @@ class CatchupSessionApiTests(TestCase): self.assertEqual(data["channel_uuid"], str(self.channel.uuid)) self.assertEqual(data["start"], "2026-06-08T17:00:00Z") self.assertGreater(data["expires_at"], int(time.time())) + self.assertIsNone(data["duration"]) + + @patch.object(sessions.RedisClient, "get_client") + @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) + def test_post_accepts_duration(self, _net, redis_mock): + redis_mock.return_value = self.redis + response = self.client.post( + self._create_url(), + { + "channel_uuid": str(self.channel.uuid), + "start": "2026-06-08T17:00:00Z", + "duration": 30, + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["duration"], 30) + + @patch.object(sessions.RedisClient, "get_client") + @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) + def test_post_rejects_out_of_range_duration(self, _net, redis_mock): + redis_mock.return_value = self.redis + response = self.client.post( + self._create_url(), + { + "channel_uuid": str(self.channel.uuid), + "start": "2026-06-08T17:00:00Z", + "duration": 0, + }, + format="json", + ) + self.assertEqual(response.status_code, 400) @patch.object(sessions.RedisClient, "get_client") @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) @@ -174,6 +206,139 @@ class CatchupSessionApiTests(TestCase): self.assertEqual(deleted.status_code, 404) +@override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "catchup-session-position-tests", + } + }, + REST_FRAMEWORK={ + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "apps.accounts.authentication.ApiKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "apps.accounts.permissions.IsAdmin", + ], + }, +) +class CatchupSessionPositionApiTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create( + username="catchup-position-user", + user_level=User.UserLevel.STANDARD, + ) + cls.account = M3UAccount.objects.create( + name="catchup-position-acct", + server_url="http://example.test", + account_type="XC", + is_active=True, + ) + cls.channel = Channel.objects.create( + name="Catchup Position Channel", + is_catchup=True, + catchup_days=7, + ) + cls.stream = Stream.objects.create( + name="catchup-position-stream", + url="http://example.test/live", + m3u_account=cls.account, + is_catchup=True, + catchup_days=7, + custom_properties={"stream_id": "111"}, + ) + ChannelStream.objects.create( + channel=cls.channel, stream=cls.stream, order=0, + ) + + def setUp(self): + from apps.timeshift.tests.test_views import _FakeRedis + + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.redis = _FakeRedis() + + def _seed_active_playback(self, session_id): + stats_channel_id = f"{self.channel.id}_{session_id}" + self.redis.hset( + TimeshiftRedisKeys.channel_metadata(stats_channel_id), + mapping={"state": "active"}, + ) + self.redis.sadd(TimeshiftRedisKeys.clients(stats_channel_id), session_id) + self.redis.hset( + TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id), + mapping={ + "user_id": str(self.user.id), + "username": self.user.username, + "programme_start": "2026-06-08T17:00:00Z", + "position_anchor_at": "1000.0", + }, + ) + return stats_channel_id + + @patch("apps.timeshift.api_views._trigger_timeshift_stats_update") + @patch("apps.timeshift.api_views.RedisClient.get_client") + @patch("apps.timeshift.stats.RedisClient.get_client") + @patch("apps.timeshift.sessions.RedisClient.get_client") + @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) + def test_position_updates_active_session( + self, _net, sessions_redis, stats_redis, api_redis, trigger_mock, + ): + sessions_redis.return_value = self.redis + stats_redis.return_value = self.redis + api_redis.return_value = self.redis + created = self.client.post( + "/api/catchup/sessions/", + { + "channel_uuid": str(self.channel.uuid), + "start": "2026-06-08T17:00:00Z", + }, + format="json", + ) + session_id = created.json()["session_id"] + stats_channel_id = self._seed_active_playback(session_id) + + response = self.client.post( + f"/api/catchup/sessions/{session_id}/position/", + {"position_secs": 842, "paused": True}, + format="json", + ) + self.assertEqual(response.status_code, 204) + client_key = TimeshiftRedisKeys.client_metadata(stats_channel_id, session_id) + data = self.redis.hgetall(client_key) + self.assertEqual(data["playback_base_secs"], "842.0") + self.assertEqual(data["paused"], "1") + trigger_mock.assert_called_once() + + @patch("apps.timeshift.api_views.RedisClient.get_client") + @patch("apps.timeshift.stats.RedisClient.get_client") + @patch("apps.timeshift.sessions.RedisClient.get_client") + @patch("apps.timeshift.api_views.network_access_allowed", return_value=True) + def test_position_without_playback_returns_404( + self, _net, sessions_redis, stats_redis, api_redis, + ): + sessions_redis.return_value = self.redis + stats_redis.return_value = self.redis + api_redis.return_value = self.redis + created = self.client.post( + "/api/catchup/sessions/", + { + "channel_uuid": str(self.channel.uuid), + "start": "2026-06-08T17:00:00Z", + }, + format="json", + ) + session_id = created.json()["session_id"] + response = self.client.post( + f"/api/catchup/sessions/{session_id}/position/", + {"position_secs": 10}, + format="json", + ) + self.assertEqual(response.status_code, 404) + + class CatchupSessionResolveTests(TestCase): @classmethod def setUpTestData(cls): @@ -235,6 +400,32 @@ class CatchupSessionResolveTests(TestCase): sessions.resolve_catchup_playback(session_id, other_uuid), ) + @patch.object(sessions.RedisClient, "get_client") + def test_create_and_resolve_round_trips_duration(self, redis_mock): + redis_mock.return_value = self.redis + payload = sessions.create_catchup_session( + user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z", + duration=30, + ) + self.assertEqual(payload["duration"], 30) + resolved = sessions.resolve_catchup_playback( + payload["session_id"], self.channel.uuid, + ) + self.assertIsNotNone(resolved) + self.assertEqual(resolved[2], "30") + + @patch.object(sessions.RedisClient, "get_client") + def test_create_without_duration_resolves_none(self, redis_mock): + redis_mock.return_value = self.redis + payload = sessions.create_catchup_session( + user=self.user, channel=self.channel, start="2026-06-08T17:00:00Z", + ) + self.assertIsNone(payload["duration"]) + resolved = sessions.resolve_catchup_playback( + payload["session_id"], self.channel.uuid, + ) + self.assertIsNone(resolved[2]) + class CatchupProxySessionAuthTests(TestCase): """Playback via API session without JWT.""" @@ -252,7 +443,7 @@ class CatchupProxySessionAuthTests(TestCase): self, channel_cls, _access, serve, _net, resolve_mock, ): user = MagicMock(id=42, is_authenticated=False) - resolve_mock.return_value = (user, "2026-06-08T17:00:00Z") + resolve_mock.return_value = (user, "2026-06-08T17:00:00Z", None) channel_cls.objects.get.return_value = MagicMock( id=8, uuid=self.channel_uuid, ) @@ -277,7 +468,7 @@ class CatchupProxySessionAuthTests(TestCase): @patch.object(views, "resolve_catchup_playback") @patch.object(views, "network_access_allowed", return_value=True) def test_mismatched_jwt_and_session_returns_403(self, _net, resolve_mock): - resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z") + resolve_mock.return_value = (MagicMock(id=1), "2026-06-08T17:00:00Z", None) request = self.factory.get( f"/proxy/catchup/{self.channel_uuid}?session_id=test", ) @@ -312,7 +503,7 @@ class CatchupProxySessionAuthTests(TestCase): patch.object(views, "_serve_catchup", return_value=HttpResponse("ok")) as serve: channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 200) serve.assert_called_once() diff --git a/apps/timeshift/tests/test_stats.py b/apps/timeshift/tests/test_stats.py index de60355a..0503a4ea 100644 --- a/apps/timeshift/tests/test_stats.py +++ b/apps/timeshift/tests/test_stats.py @@ -21,6 +21,7 @@ from apps.timeshift.stats import ( resolve_stats_playback_fields, seed_stream_stats_metadata, stream_stats_to_metadata_fields, + update_catchup_session_position, ) from apps.timeshift.tests.test_views import _FakeRedis @@ -55,6 +56,43 @@ class GetProgrammeInfoTests(TestCase): self.assertEqual(info["sub_title"], "Local Edition") self.assertEqual(info["duration_secs"], 45 * 60) + def test_get_programme_info_advances_past_end(self): + from datetime import datetime, timedelta, timezone as dt_timezone + from unittest.mock import MagicMock + + start = datetime(2026, 6, 8, 17, 0, tzinfo=dt_timezone.utc) + first = MagicMock( + title="Show A", + sub_title="", + description="", + start_time=start, + end_time=start + timedelta(minutes=30), + ) + second = MagicMock( + title="Show B", + sub_title="", + description="", + start_time=start + timedelta(minutes=30), + end_time=start + timedelta(minutes=60), + ) + channel = MagicMock() + + def _filter(**kwargs): + qs = MagicMock() + dt = kwargs["start_time__lte"] + chosen = None + for prog in (first, second): + if prog.start_time <= dt and prog.end_time > dt: + chosen = prog + break + qs.first.return_value = chosen + return qs + + channel.epg_data.programs.filter.side_effect = _filter + info = get_programme_info(channel, "2026-06-08:17-00", position_secs=31 * 60) + self.assertEqual(info["title"], "Show B") + self.assertEqual(info["duration_secs"], 30 * 60) + class ComputePlaybackPositionTests(TestCase): EPG_START = "2026-07-10T14:00:00+00:00" @@ -108,6 +146,18 @@ class ComputePlaybackPositionTests(TestCase): ) self.assertAlmostEqual(pos, 5 * 60) + def test_paused_freezes_wall_clock_advance(self): + pos = compute_playback_position_secs( + "2026-07-10:14-00", + self.EPG_START, + position_anchor_at=1000.0, + current_time=1300.0, + duration_secs=3600, + playback_base_secs=900.0, + paused=True, + ) + self.assertAlmostEqual(pos, 900.0) + class ByteRangePlaybackTests(TestCase): def test_compute_playback_base_from_byte_range(self): @@ -144,6 +194,22 @@ class ByteRangePlaybackTests(TestCase): self.assertIsNone(base) self.assertEqual(anchor, "2000.0") + def test_resolve_near_eof_probe_keeps_existing_position(self): + # Clients probe ~1.88MB from EOF for duration; must not flash to end. + total = 8_783_238_116 + base, anchor = resolve_stats_playback_fields( + timestamp_utc="2026-07-14:14-59", + existing_programme_start="2026-07-14:14-59", + existing_position_anchor="1000.0", + existing_playback_base="2100.0", + range_start=total - 1_880_000, + representation_length=total, + programme_duration_secs=3600, + now="2000.0", + ) + self.assertAlmostEqual(base, 2100.0) + self.assertEqual(anchor, "1000.0") + class TimeshiftStreamStatsTests(TestCase): def test_stream_stats_to_metadata_fields(self): @@ -248,12 +314,45 @@ class BuildTimeshiftStatsDataTests(TestCase): self.assertNotIn("playback_position_secs", session) self.assertEqual(session["channel_name"], "Catch-up Stats Channel") self.assertEqual(session["resolution"], "1920x1080") + self.assertFalse(session["paused"]) self.assertEqual(session["connections"][0]["ip_address"], "10.0.0.5") def test_find_stats_channel_for_session(self): found = find_stats_channel_for_session(self.redis, self.session_id) self.assertEqual(found, self.stats_channel_id) + def test_update_catchup_session_position_sets_base_and_pause(self): + updated = update_catchup_session_position( + self.session_id, + position_secs=842.5, + paused=True, + user_id=1, + redis_client=self.redis, + ) + self.assertTrue(updated) + client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id) + data = self.redis.hgetall(client_key) + self.assertEqual(data["playback_base_secs"], "842.5") + self.assertEqual(data["paused"], "1") + self.assertIsNotNone(data.get("position_anchor_at")) + + @patch("apps.timeshift.stats.Channel") + def test_build_includes_paused_flag(self, mock_channel_model): + channel = MagicMock() + channel.id = self.channel_id + channel.name = "Catch-up Stats Channel" + channel.uuid = "00000000-0000-0000-0000-000000000042" + channel.logo_id = None + channel.logo = None + mock_channel_model.objects.select_related.return_value.filter.return_value = [ + channel, + ] + client_key = RedisKeys.client_metadata(self.stats_channel_id, self.session_id) + self.redis.hset(client_key, mapping={"paused": "1", "playback_base_secs": "100"}) + session = build_timeshift_stats_data(self.redis)["timeshift_sessions"][0] + self.assertTrue(session["paused"]) + self.assertEqual(session["playback_base_secs"], 100.0) + @patch("apps.timeshift.stats.Channel") def test_skips_clients_missing_required_metadata(self, mock_channel_model): mock_channel_model.objects.select_related.return_value.filter.return_value = [] diff --git a/apps/timeshift/tests/test_views.py b/apps/timeshift/tests/test_views.py index 9aab8306..92ab6890 100644 --- a/apps/timeshift/tests/test_views.py +++ b/apps/timeshift/tests/test_views.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import requests from django.http import HttpResponse from django.test import RequestFactory, TestCase, override_settings +from django.urls import resolve from rest_framework.test import APIRequestFactory, force_authenticate from apps.timeshift import views @@ -19,7 +20,7 @@ TEST_MEDIA_ID = "8_2026-06-08-17-00" def _proxy_url(session_id=TEST_SESSION_ID): - base = "/timeshift/u/p/8/2026-06-08:17-00/8.ts" + base = "/timeshift/u/p/40/2026-06-08:17-00/8.ts" return f"{base}?session_id={session_id}" if session_id else base @@ -69,7 +70,7 @@ class FindTsSyncTests(TestCase): self.assertEqual(_find_ts_sync(preamble + aligned), len(preamble)) def test_returns_minus_one_when_no_chain_exists(self): - # Three lone 0x47 bytes that are NOT spaced at 188 — must not be + # Three lone 0x47 bytes that are NOT spaced at 188 - must not be # mistaken for a sync chain. self.assertEqual(_find_ts_sync(b"\x47\x00\x00\x47\x00\x00\x47" * 50), -1) @@ -84,10 +85,11 @@ def _make_ts_payload(size=1024): return (packet * ((size // 188) + 1))[:size] -def _fake_upstream(status_code, *, content_type="video/mp2t", body=b""): +def _fake_upstream(status_code, *, content_type="video/mp2t", body=b"", url=None): resp = MagicMock() resp.status_code = status_code resp.headers = {"Content-Type": content_type} + resp.url = url or "http://cdn.example.test/timeshift.ts" resp.iter_content = MagicMock(return_value=iter([body] if body else [])) resp.close = MagicMock() # Simulate raw.read() for the TS sync peek in _stream_from_provider. @@ -141,12 +143,104 @@ class StreamFromProviderStatusMappingTests(TestCase): @patch.object(views, "_open_upstream") def test_upstream_403_short_circuits_loop(self, mocked_open): - # 403 is decisive (auth) — no retry of further candidates. + # 403 is decisive (auth) - no retry of further candidates. mocked_open.return_value = _fake_upstream(403) response = views._stream_from_provider(**self.kwargs) self.assertEqual(response.status_code, 403) self.assertEqual(mocked_open.call_count, 1) + @patch.object(views, "_open_upstream") + def test_stores_final_url_after_successful_open(self, mocked_open): + redis = _FakeRedis() + session_id = "sess-cdn-store" + cdn = "http://cdn.example.test/tok/archive.ts" + mocked_open.return_value = _fake_upstream( + 200, body=_make_ts_payload(), url=cdn, + ) + with patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider( + **self.kwargs, + redis_client=redis, + pool_session_id=session_id, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + redis.hget(views._pool_key(session_id), "final_url"), cdn, + ) + + @patch.object(views, "_open_upstream") + def test_reuses_cached_final_url_without_portal(self, mocked_open): + cdn = "http://cdn.example.test/tok/archive.ts" + mocked_open.return_value = _fake_upstream( + 200, body=_make_ts_payload(), url=cdn, + ) + with patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider( + **self.kwargs, final_url=cdn, + ) + self.assertEqual(response.status_code, 200) + mocked_open.assert_called_once() + self.assertEqual(mocked_open.call_args.args[0], cdn) + self.assertFalse(mocked_open.call_args.kwargs.get("allow_redirects", True)) + + @patch.object(views, "_open_upstream") + def test_xc_scrub_rewrite_does_not_byte_map_stats_position(self, mocked_open): + # Injected CDN Range is for the provider only; stats must follow the XC + # URL timestamp, not archive_offset/programme_duration (false "26:00"). + cdn = "http://cdn.example.test/timeshift/u/p/60/2026-05-12:17-00/1.ts?token=x" + upstream = _fake_upstream(206, body=_make_ts_payload(), url=cdn) + upstream.headers["Content-Range"] = "bytes 500000000-999999999/1000000000" + upstream.headers["Content-Length"] = "500000000" + mocked_open.return_value = upstream + kwargs = dict( + self.kwargs, + final_url=cdn, + range_header="bytes=500000000-", + rewrite_plain_get=True, + presentation_remaining=500000000, + presentation_byte_base=500000000, + duration_minutes=120, + ) + with patch.object(views, "_register_stats_client") as register_mock, \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider(**kwargs) + self.assertEqual(response.status_code, 200) + self.assertIsNone(register_mock.call_args.kwargs.get("range_start")) + + @patch.object(views, "_open_upstream") + def test_expired_final_url_falls_back_to_portal(self, mocked_open): + cdn = "http://cdn.example.test/expired.ts" + portal = self.kwargs["candidate_urls"][0] + redis = _FakeRedis() + session_id = "sess-cdn-expire" + redis.hset(views._pool_key(session_id), "final_url", cdn) + mocked_open.side_effect = [ + _fake_upstream(403, url=cdn), + _fake_upstream(200, body=_make_ts_payload(), url=cdn + "?fresh=1"), + ] + with patch.object(views, "_register_stats_client"), \ + patch.object(views, "_unregister_stats_client"): + response = views._stream_from_provider( + **self.kwargs, + final_url=cdn, + redis_client=redis, + pool_session_id=session_id, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked_open.call_count, 2) + self.assertEqual(mocked_open.call_args_list[0].args[0], cdn) + self.assertEqual(mocked_open.call_args_list[1].args[0], portal) + self.assertTrue( + mocked_open.call_args_list[1].kwargs.get("allow_redirects", True) + ) + # Fresh portal response re-stores CDN URL. + self.assertEqual( + redis.hget(views._pool_key(session_id), "final_url"), + cdn + "?fresh=1", + ) + @patch.object(views, "_open_upstream") def test_upstream_302_short_circuits_loop(self, mocked_open): # Any 3xx is decisive: for XC providers a 302 is the first sign of @@ -161,7 +255,7 @@ class StreamFromProviderStatusMappingTests(TestCase): def test_upstream_500_continues_to_next_candidate(self, mocked_open): # A 5xx is format-specific on many XC servers (PHP fatal with # display_errors off turns an "Undefined array key" warning into a - # hard 500), so the cascade must keep trying — the next timestamp + # hard 500), so the cascade must keep trying - the next timestamp # shape often succeeds. Regression: providers that 500 on the first # shape used to fail outright because the loop short-circuited. mocked_open.side_effect = [ @@ -258,7 +352,7 @@ class StreamFromProviderStatusMappingTests(TestCase): @patch.object(views, "_open_upstream") def test_cache_promotes_winning_index_to_first(self, mocked_open): """Once a candidate succeeds for an account, the next request reorders - the list so the cached winner is tried first — saving cascade + the list so the cached winner is tried first - saving cascade overhead on fast-forward.""" # locmem cache: isolates this test from the shared Redis-backed django # cache (which persists across runs and parallel test sessions). @@ -279,7 +373,7 @@ class StreamFromProviderStatusMappingTests(TestCase): self.assertEqual(mocked_open.call_count, 2) # Second request: cached winner (index 1) is tried first, succeeds - # immediately — no cascade. + # immediately - no cascade. mocked_open.reset_mock() mocked_open.side_effect = [_fake_upstream(200, body=_make_ts_payload())] with patch.object(views, "RedisClient"), \ @@ -414,7 +508,7 @@ class StreamFromProviderStatusMappingTests(TestCase): class RedactUrlTests(TestCase): - """`_redact_url` is the guard that keeps XC credentials out of logs — + """`_redact_url` is the guard that keeps XC credentials out of logs - both URL forms carry them (query params in format A, path segments in format B).""" @@ -437,12 +531,12 @@ class RedactUrlTests(TestCase): def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9, stream_id="22372", account_type="XC", profile_id=31, - extra_profiles=()): + extra_profiles=(), catchup_days=0): """Build a mocked catch-up Stream with its own provider context. The default (tz-bearing) profile leads the active-profile list the view walks; ``extra_profiles`` appends alternate (non-default) profiles for - capacity-walk tests. + capacity-walk tests. ``catchup_days`` defaults to 0 (unknown archive depth). """ profile = MagicMock() profile.id = profile_id @@ -456,6 +550,7 @@ def _make_catchup_stream(provider_tz="Europe/Brussels", *, account_id=9, stream.m3u_account = m3u_account stream.m3u_account_id = account_id stream.custom_properties = {"stream_id": stream_id} if stream_id else {} + stream.catchup_days = catchup_days return stream @@ -685,14 +780,14 @@ def _fake_creds(acc, prof): class TimeshiftProxyTimestampWiringTests(TestCase): """`timeshift_proxy` must convert the client's UTC timestamp to the serving provider's zone for the upstream URL, while keeping the ORIGINAL - UTC timestamp for the EPG duration lookup — the only timezone conversion + UTC timestamp for the EPG duration lookup - the only timezone conversion in the chain.""" def setUp(self): self.factory = RequestFactory() def _call(self, timestamp, provider_tz="Europe/Brussels"): - request = self.factory.get(f"/timeshift/u/p/8/{timestamp}/8.ts?session_id={TEST_SESSION_ID}") + request = self.factory.get(f"/timeshift/u/p/40/{timestamp}/8.ts?session_id={TEST_SESSION_ID}") sentinel = MagicMock(status_code=200) with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ @@ -700,7 +795,9 @@ class TimeshiftProxyTimestampWiringTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream(provider_tz)]), \ - patch.object(views, "get_programme_duration", return_value=40) as duration_mock, \ + patch( + "apps.timeshift.helpers.get_programme_duration", return_value=999, + ) as duration_mock, \ patch.object(views, "build_timeshift_candidate_urls", return_value=["http://example.test/x.ts"]) as build_mock, \ patch.object(views, "check_user_stream_limits", return_value=True), \ @@ -712,7 +809,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): patch.object(views, "_stream_from_provider", return_value=sentinel) as stream_mock: redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) - response = views.timeshift_proxy(request, "u", "p", "8", timestamp, "8.ts") + response = views.timeshift_proxy(request, "u", "p", "40", timestamp, "8.ts") return response, sentinel, build_mock, duration_mock, stream_mock def test_candidates_get_provider_local_timestamp(self): @@ -721,11 +818,14 @@ class TimeshiftProxyTimestampWiringTests(TestCase): self.assertIs(response, sentinel) self.assertEqual(build_mock.call_args[0][2], "2026-06-08:19-00") - def test_duration_lookup_keeps_original_utc_timestamp(self): - # The EPG is stored in UTC — the duration lookup must NOT receive the - # provider-converted value. - _, _, _, duration_mock, _ = self._call("2026-06-08:17-00") - self.assertEqual(duration_mock.call_args[0][1], "2026-06-08:17-00") + def test_path_duration_preferred_over_epg(self): + # PATH ``/timeshift/.../40/.../8.ts`` is programme minutes; +5 buffer + # becomes 45. EPG lookup must not run when the client supplied a + # usable duration. + _, _, build_mock, duration_mock, stream_mock = self._call("2026-06-08:17-00") + duration_mock.assert_not_called() + self.assertEqual(build_mock.call_args[0][3], 45) + self.assertEqual(stream_mock.call_args.kwargs["duration_minutes"], 45) def test_utc_provider_passes_timestamp_unchanged(self): _, _, build_mock, _, _ = self._call("2026-06-08:17-00", provider_tz="UTC") @@ -736,10 +836,11 @@ class TimeshiftProxyTimestampWiringTests(TestCase): "2026-06-23:04:00:00" ) self.assertIs(response, sentinel) - self.assertEqual(duration_mock.call_args[0][1], "2026-06-23:04:00:00") + duration_mock.assert_not_called() + self.assertEqual(build_mock.call_args[0][3], 45) def test_invalid_timestamp_rejected_before_upstream(self): - request = self.factory.get("/timeshift/u/p/8/garbage/8.ts") + request = self.factory.get("/timeshift/u/p/40/garbage/8.ts") with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ patch.object(views, "network_access_allowed", return_value=True), \ patch.object(views, "Channel") as channel_cls, \ @@ -747,7 +848,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): patch.object(views, "get_channel_catchup_streams") as catchup_mock, \ patch.object(views, "_stream_from_provider") as stream_mock: channel_cls.objects.get.return_value = MagicMock(id=8) - response = views.timeshift_proxy(request, "u", "p", "8", "garbage", "8.ts") + response = views.timeshift_proxy(request, "u", "p", "40", "garbage", "8.ts") self.assertEqual(response.status_code, 400) catchup_mock.assert_not_called() stream_mock.assert_not_called() @@ -760,7 +861,7 @@ class TimeshiftProxyTimestampWiringTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_stream_from_provider") as stream_mock: response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + request, "u", "p", "40", "2026-06-08:17-00", "8.ts" ) self.assertEqual(response.status_code, 403) self.assertEqual(gate.call_args[0][1], "XC_API") @@ -768,9 +869,60 @@ class TimeshiftProxyTimestampWiringTests(TestCase): stream_mock.assert_not_called() +class TimeshiftProxyQueryRoutingTests(TestCase): + """QUERY-layout ``/streaming/timeshift.php`` must resolve to + ``timeshift_proxy_query``; PATH-layout ``/timeshift/...`` must still + resolve to ``timeshift_proxy``.""" + + def test_query_style_path_resolves_to_timeshift_proxy_query(self): + match = resolve("/streaming/timeshift.php") + self.assertIs(match.func, views.timeshift_proxy_query) + + def test_path_style_still_resolves_to_timeshift_proxy(self): + match = resolve("/timeshift/u/p/40/2026-06-08:17-00/8.ts") + self.assertIs(match.func, views.timeshift_proxy) + + +class TimeshiftProxyQueryParamMappingTests(TestCase): + """`timeshift_proxy_query` must extract the same fields from the + querystring that `timeshift_proxy` receives as URL kwargs, and reject + the request before touching auth/DB when required params are absent.""" + + def setUp(self): + self.factory = RequestFactory() + + def test_delegates_with_mapped_params(self): + request = self.factory.get( + "/streaming/timeshift.php", + { + "username": "u", + "password": "p", + "stream": "8", + "start": "2026-06-08:17-00", + "duration": "40", + }, + ) + with patch.object(views, "_timeshift_proxy_impl", return_value=HttpResponse()) as impl: + views.timeshift_proxy_query(request) + impl.assert_called_once_with( + request, "u", "p", "2026-06-08:17-00", "8", + client_duration_hint="40", + ) + + def test_missing_stream_param_returns_400_without_touching_impl(self): + request = self.factory.get( + "/streaming/timeshift.php", + {"username": "u", "password": "p", "start": "2026-06-08:17-00"}, + ) + with patch.object(views, "_timeshift_proxy_impl") as impl: + response = views.timeshift_proxy_query(request) + self.assertEqual(response.status_code, 400) + impl.assert_not_called() + + class TimeshiftProxyFailoverTests(TestCase): """When the first catch-up stream's provider cannot serve the archive, - the proxy must fail over to the channel's next catch-up stream — each + the proxy must fail over to the channel's next catch-up stream - each attempt with its own provider context.""" def setUp(self): @@ -783,7 +935,7 @@ class TimeshiftProxyFailoverTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "build_timeshift_candidate_urls", return_value=["http://example.test/x.ts"]) as build_mock, \ patch.object(views, "check_user_stream_limits", return_value=True) as limits_mock, \ @@ -798,7 +950,7 @@ class TimeshiftProxyFailoverTests(TestCase): redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + request, "u", "p", "40", "2026-06-08:17-00", "8.ts" ) self.creds_mock = creds_mock return response, stream_mock, build_mock, limits_mock @@ -880,9 +1032,41 @@ class TimeshiftProxyFailoverTests(TestCase): self.assertIs(response, passthrough) self.assertEqual(stream_mock.call_count, 1) + @patch("apps.timeshift.helpers.programme_age_days", return_value=4) + def test_prefers_stream_with_sufficient_catchup_days(self, _age): + """Deep archive is tried first when earlier streams are too short.""" + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", catchup_days=2), + _make_catchup_stream(account_id=2, stream_id="222", catchup_days=1), + _make_catchup_stream(account_id=3, stream_id="333", catchup_days=5), + ] + ok = MagicMock(status_code=200) + response, stream_mock, build_mock, _ = self._call(streams, [ok]) + self.assertIs(response, ok) + self.assertEqual(stream_mock.call_count, 1) + self.assertEqual(stream_mock.call_args.kwargs["account_id"], 3) + self.assertEqual(build_mock.call_args.args[1], "333") + + @patch("apps.timeshift.helpers.programme_age_days", return_value=4) + def test_falls_back_to_shorter_archives_when_preferred_fail(self, _age): + streams = [ + _make_catchup_stream(account_id=1, stream_id="111", catchup_days=2), + _make_catchup_stream(account_id=2, stream_id="222", catchup_days=1), + _make_catchup_stream(account_id=3, stream_id="333", catchup_days=5), + ] + ok = MagicMock(status_code=200) + response, stream_mock, _, _ = self._call( + streams, [MagicMock(status_code=404), ok] + ) + self.assertIs(response, ok) + self.assertEqual( + [c.kwargs["account_id"] for c in stream_mock.call_args_list], + [3, 1], + ) + class _ProxyLoopTestMixin: - """Shared driver for tests exercising the failover loop end to end — + """Shared driver for tests exercising the failover loop end to end - pool reservation, credential resolution and Redis are all controlled.""" def setUp(self): @@ -907,7 +1091,7 @@ class _ProxyLoopTestMixin: patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "build_timeshift_candidate_urls", **build_kwargs) as build_mock, \ patch.object(views, "check_user_stream_limits", return_value=limits), \ @@ -927,7 +1111,7 @@ class _ProxyLoopTestMixin: self.creds_mock = creds_mock self.stream_mock = stream_mock response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + request, "u", "p", "40", "2026-06-08:17-00", "8.ts" ) return response, stream_mock, build_mock @@ -938,7 +1122,7 @@ class TimeshiftProxyFailoverHardeningTests(_ProxyLoopTestMixin, TestCase): def test_decisive_failure_skips_same_accounts_other_streams(self): # Account 1 carries two variants (e.g. FHD + HD). A decisive # (auth/ban-class) failure on the first must NOT retry account 1's - # second stream — that would hammer a banning provider — but a + # second stream - that would hammer a banning provider - but a # DIFFERENT account stays fair game. streams = [ _make_catchup_stream(account_id=1, stream_id="111"), @@ -956,7 +1140,7 @@ class TimeshiftProxyFailoverHardeningTests(_ProxyLoopTestMixin, TestCase): def test_soft_failure_still_tries_same_accounts_other_streams(self): # A soft failure (404: this stream's archive missing) is stream- - # specific — the same account's other variant may still have it. + # specific - the same account's other variant may still have it. streams = [ _make_catchup_stream(account_id=1, stream_id="111"), _make_catchup_stream(account_id=1, stream_id="112"), @@ -1057,7 +1241,7 @@ class StreamFromProviderDecisiveEdgeTests(TestCase): @patch.object(views, "_open_upstream") def test_406_is_decisive_and_marks_response(self, mocked_open): - # 406 = IP-wide block in the XC ban escalation — single attempt, + # 406 = IP-wide block in the XC ban escalation - single attempt, # generic 400 to the client, and the failover loop must see the # decisive marker so it skips this account's other streams. mocked_open.return_value = _fake_upstream(406) @@ -1087,7 +1271,7 @@ class StreamFromProviderDecisiveEdgeTests(TestCase): class CatchupStreamsDbTests(TestCase): """get_channel_catchup_streams: the function that defines the failover - order — channelstream order, catch-up streams only, active accounts only.""" + order - channelstream order, catch-up streams only, active accounts only.""" @classmethod def setUpTestData(cls): @@ -1134,7 +1318,7 @@ class CatchupStreamsDbTests(TestCase): class AuthHelpersDbTests(TestCase): """_authenticate_user (xc_password custom property) and - _user_can_access_channel (user_level gate) — exercised against real models + _user_can_access_channel (user_level gate) - exercised against real models instead of being mocked away.""" @classmethod @@ -1163,7 +1347,7 @@ class AuthHelpersDbTests(TestCase): def test_user_without_xc_password_rejected(self): # Accounts with no xc_password set (e.g. admins) must be denied even - # if the caller guesses any string — there is nothing to compare to. + # if the caller guesses any string - there is nothing to compare to. self.assertIsNone(views._authenticate_user("ts-test-noxc", "")) self.assertIsNone(views._authenticate_user("ts-test-noxc", "anything")) @@ -1269,7 +1453,7 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): def test_capacity_failure_is_not_decisive_for_the_account(self): # profile_full on account 1's first stream must NOT mark account 1 - # decisive — capacity is transient, unlike a ban-class status. + # decisive - capacity is transient, unlike a ban-class status. streams = [ _make_catchup_stream(account_id=1, stream_id="111", profile_id=31), _make_catchup_stream(account_id=1, stream_id="112", profile_id=31), @@ -1295,7 +1479,7 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): def test_exception_from_provider_releases_slot(self): # An unexpected exception between reserve and response construction - # must release the slot before propagating — otherwise the counter + # must release the slot before propagating - otherwise the counter # (no TTL) leaks until the next Redis flush. streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] with self.assertRaises(RuntimeError): @@ -1305,7 +1489,7 @@ class TimeshiftSlotPoolTests(_ProxyLoopTestMixin, TestCase): def test_exception_before_upstream_releases_slot(self): # Same guarantee for failures BEFORE the upstream call (URL building, - # credential resolution, user-agent lookup) — the guarded window + # credential resolution, user-agent lookup) - the guarded window # starts right after the reservation. streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] with self.assertRaises(RuntimeError): @@ -1555,7 +1739,7 @@ class TimeshiftTakeoverTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "_terminate_previous_timeshift_sessions", side_effect=lambda *a: call_order.append("takeover")) as takeover_mock, \ @@ -1564,7 +1748,7 @@ class TimeshiftTakeoverTests(TestCase): redis_cls.get_client.return_value = self.redis channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts" + request, "u", "p", "40", "2026-06-08:17-00", "8.ts" ) self.assertEqual(response.status_code, 403) self.assertEqual(call_order, ["takeover", "limits"]) @@ -1590,6 +1774,16 @@ class TimeshiftSessionReuseTests(TestCase): with patch.object(views, "release_profile_slot"): views._release_pool_session(self.redis, self.SESSION, 31) + def test_store_pool_provider_user_agent_snapshots_resolved_value(self): + _seed_pool_session(self.redis, session_id=self.SESSION) + views._store_pool_provider_user_agent( + self.redis, self.SESSION, "provider-agent", + ) + self.assertEqual( + self.redis.hget(self._pool_key(), "provider_user_agent"), + "provider-agent", + ) + def test_wait_returns_none_without_blocking_when_pool_empty(self): start = time.monotonic() acquired = views._wait_for_idle_pool_session(self.redis, self.SESSION) @@ -1648,7 +1842,7 @@ class TimeshiftSessionReuseTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "_acquire_idle_pool_session") as acquire_mock, \ @@ -1656,7 +1850,7 @@ class TimeshiftSessionReuseTests(TestCase): redis_cls.get_client.return_value = self.redis channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 301) self.assertIn("session_id=", response["Location"]) @@ -1773,7 +1967,7 @@ class TimeshiftSessionReuseTests(TestCase): self.assertEqual(matched, "old") def test_fresh_session_id_does_not_adopt_idle_exact_media_pool(self): - """TiviMate FF race: redirect mints a new session, then reconnects to + """FF race: redirect mints a new session, then reconnects to the old programme timestamp before the real seek arrives. Must not fingerprint-adopt the idle pool from the previous session.""" old_session = "oldrewsession1" @@ -1800,7 +1994,7 @@ class TimeshiftSessionReuseTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ @@ -1814,7 +2008,7 @@ class TimeshiftSessionReuseTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) acquire_mock.assert_not_called() @@ -1880,7 +2074,7 @@ class TimeshiftSessionReuseTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "_find_matching_pool_session", return_value=None), \ patch.object(views, "_attempt_timeshift_stream", @@ -1894,7 +2088,7 @@ class TimeshiftSessionReuseTests(TestCase): channel_cls.objects.get.return_value = MagicMock(id=8, name="Test", logo_id=None) with patch.object(redis, "hgetall", wraps=redis.hgetall) as hgetall_mock: views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) pool_key = "timeshift:pool:newsession1" self.assertEqual( @@ -1923,7 +2117,7 @@ class TimeshiftSessionReuseTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", @@ -1943,7 +2137,7 @@ class TimeshiftSessionReuseTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) self.assertEqual(stream_mock.call_count, 2) @@ -1987,8 +2181,381 @@ class TimeshiftSessionReuseTests(TestCase): ) return response, ok, attempt_mock + def test_session_scrub_reuses_final_url_and_injects_range(self): + # Client FF rebuilds /timeshift/...//... with the same + # session_id. That must Range-seek the open CDN archive, not portal. + _seed_pool_session(self.redis, session_id=self.SESSION) + cdn = "http://cdn.example/archive.ts?token=ok" + self.redis.hset(self._pool_key(), mapping={ + "final_url": cdn, + "content_length": "1800000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": TEST_MEDIA_ID, + "provider_timestamp": "2026-06-08:19-00", + "provider_tz_name": "Europe/Brussels", + "final_url": cdn, + "content_length": "1800000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + }, + profile=profile, + channel=self.channel, + media_id="8_2026-06-08-17-30", + safe_ts="2026-06-08-17-30", + timestamp="2026-06-08:17-30", + duration_minutes=40, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + kwargs = attempt_mock.call_args.kwargs + self.assertEqual(kwargs.get("final_url"), cdn) + self.assertTrue(kwargs.get("rewrite_plain_get")) + self.assertTrue(kwargs.get("range_header", "").startswith("bytes=")) + self.assertIsNotNone(kwargs.get("presentation_remaining")) + self.assertIsNotNone(kwargs.get("presentation_byte_base")) + # Archive CDN state must survive the media_id move. + self.assertEqual(self.redis.hget(self._pool_key(), "final_url"), cdn) + + def test_session_scrub_reuses_opaque_final_url(self): + """Opaque CDNs still scrub via Range on the cached URL (no portal hop).""" + _seed_pool_session(self.redis, session_id=self.SESSION) + opaque = "http://opaque-cdn.example/hash/token/t5/test-stream/serve" + self.redis.hset(self._pool_key(), mapping={ + "final_url": opaque, + "content_length": "722718720", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "2100", + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": TEST_MEDIA_ID, + "provider_timestamp": "2026-06-08:19-00", + "provider_tz_name": "Europe/Brussels", + "final_url": opaque, + "content_length": "722718720", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "2100", + }, + profile=profile, + channel=self.channel, + media_id="8_2026-06-08-17-11", + safe_ts="2026-06-08-17-11", + timestamp="2026-06-08:17-11", + duration_minutes=35, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + kwargs = attempt_mock.call_args.kwargs + self.assertEqual(kwargs.get("final_url"), opaque) + self.assertTrue(kwargs.get("rewrite_plain_get")) + self.assertTrue(str(kwargs.get("range_header") or "").startswith("bytes=")) + + def test_session_range_after_scrub_maps_through_presentation_base(self): + # After a scrub rewrite, near-EOF probes are relative to the presented + # window, not the full CDN file. + _seed_pool_session(self.redis, session_id=self.SESSION) + cdn = "http://cdn.example/archive.ts?token=ok" + base = 314_934_968 + remaining = 419_913_672 + self.redis.hset(self._pool_key(), mapping={ + "final_url": cdn, + "content_length": str(base + remaining), + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + "presentation_length": str(remaining), + "presentation_byte_base": str(base), + "media_id": "8_2026-06-08-17-30", + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=206) + client_range = f"bytes={remaining - 112_800}-" + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": "8_2026-06-08-17-30", + "provider_timestamp": "2026-06-08:19-30", + "provider_tz_name": "Europe/Brussels", + "final_url": cdn, + "content_length": str(base + remaining), + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + "presentation_length": str(remaining), + "presentation_byte_base": str(base), + }, + profile=profile, + channel=self.channel, + media_id="8_2026-06-08-17-30", + safe_ts="2026-06-08-17-30", + timestamp="2026-06-08:17-30", + duration_minutes=40, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=client_range, + channel_logo_id=None, + user=self.user, + debug=False, + ) + kwargs = attempt_mock.call_args.kwargs + self.assertEqual( + kwargs.get("range_header"), + f"bytes={base + remaining - 112_800}-", + ) + self.assertTrue(kwargs.get("relative_presentation_range")) + self.assertFalse(kwargs.get("rewrite_plain_get")) + self.assertEqual(kwargs.get("final_url"), cdn) + + def test_return_to_archive_start_resets_presentation_base(self): + """Scrubbing back to the archive open must clear the prior scrub window.""" + _seed_pool_session(self.redis, session_id=self.SESSION) + cdn = "http://cdn.example/archive.ts?token=ok" + archive_total = 870_621_184 + stale_base = 348_248_380 + self.redis.hset(self._pool_key(), mapping={ + "final_url": cdn, + "content_length": str(archive_total), + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "2100", + "presentation_length": str(archive_total - stale_base), + "presentation_byte_base": str(stale_base), + "media_id": "8_2026-06-08-17-14", + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": "8_2026-06-08-17-14", + "provider_timestamp": "2026-06-08:19-14", + "provider_tz_name": "Europe/Brussels", + "final_url": cdn, + "content_length": str(archive_total), + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "2100", + "presentation_length": str(archive_total - stale_base), + "presentation_byte_base": str(stale_base), + }, + profile=profile, + channel=self.channel, + media_id=TEST_MEDIA_ID, + safe_ts="2026-06-08-17-00", + timestamp="2026-06-08:17-00", + duration_minutes=35, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + kwargs = attempt_mock.call_args.kwargs + self.assertEqual(kwargs.get("presentation_byte_base"), 0) + self.assertEqual(kwargs.get("presentation_remaining"), archive_total) + self.assertIsNone(kwargs.get("range_header")) + self.assertFalse(kwargs.get("rewrite_plain_get")) + self.assertFalse(kwargs.get("relative_presentation_range")) + + def test_return_to_archive_start_range_skips_stale_presentation_map(self): + """Ranges after return-to-start are archive-absolute, not scrub-relative.""" + _seed_pool_session(self.redis, session_id=self.SESSION) + cdn = "http://cdn.example/archive.ts?token=ok" + archive_total = 870_621_184 + stale_base = 348_248_380 + client_range = f"bytes={archive_total - 112_800}-" + self.redis.hset(self._pool_key(), mapping={ + "final_url": cdn, + "content_length": str(archive_total), + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "2100", + "presentation_length": str(archive_total - stale_base), + "presentation_byte_base": str(stale_base), + "media_id": TEST_MEDIA_ID, + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=206) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": TEST_MEDIA_ID, + "provider_timestamp": "2026-06-08:19-00", + "provider_tz_name": "Europe/Brussels", + "final_url": cdn, + "content_length": str(archive_total), + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "2100", + "presentation_length": str(archive_total - stale_base), + "presentation_byte_base": str(stale_base), + }, + profile=profile, + channel=self.channel, + media_id=TEST_MEDIA_ID, + safe_ts="2026-06-08-17-00", + timestamp="2026-06-08:17-00", + duration_minutes=35, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=client_range, + channel_logo_id=None, + user=self.user, + debug=False, + ) + kwargs = attempt_mock.call_args.kwargs + self.assertEqual(kwargs.get("range_header"), client_range) + self.assertEqual(kwargs.get("presentation_byte_base"), 0) + self.assertFalse(kwargs.get("relative_presentation_range")) + + def test_session_scrub_outside_window_forces_portal(self): + _seed_pool_session(self.redis, session_id=self.SESSION) + stale_cdn = "http://cdn.example/old.ts?token=stale" + self.redis.hset(self._pool_key(), mapping={ + "final_url": stale_cdn, + "content_length": "1800000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "1800", # 30 min window + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": TEST_MEDIA_ID, + "provider_timestamp": "2026-06-08:19-00", + "provider_tz_name": "Europe/Brussels", + "final_url": stale_cdn, + "content_length": "1800000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "1800", + }, + profile=profile, + channel=self.channel, + # 40 minutes after anchor, outside the 30 min archive window. + media_id="8_2026-06-08-17-40", + safe_ts="2026-06-08-17-40", + timestamp="2026-06-08:17-40", + duration_minutes=40, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + self.assertIsNone(attempt_mock.call_args.kwargs.get("final_url")) + self.assertIsNone(self.redis.hget(self._pool_key(), "final_url")) + + def test_same_programme_reuse_keeps_final_url(self): + _seed_pool_session(self.redis, session_id=self.SESSION) + cdn = "http://cdn.example/same.ts?token=ok" + self.redis.hset(self._pool_key(), mapping={ + "final_url": cdn, + "content_length": "1000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + }) + profile = MagicMock(id=31, custom_properties={}) + account = MagicMock(id=1) + ok = MagicMock(status_code=200) + with patch.object(views.M3UAccount.objects, "get", return_value=account), \ + patch.object(views, "_attempt_timeshift_stream", + return_value=ok) as attempt_mock: + views._stream_reused_session( + self.redis, + session_id=self.SESSION, + descriptor={ + "account_id": "1", + "stream_id": "111", + "media_id": TEST_MEDIA_ID, + "provider_timestamp": "2026-06-08:19-00", + "provider_tz_name": "Europe/Brussels", + "final_url": cdn, + "content_length": "1000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + }, + profile=profile, + channel=self.channel, + media_id=TEST_MEDIA_ID, + safe_ts="2026-06-08-17-00", + timestamp="2026-06-08:17-00", + duration_minutes=40, + client_id=self.SESSION, + client_ip="1.2.3.4", + client_user_agent="test-agent", + range_header=None, + channel_logo_id=None, + user=self.user, + debug=False, + ) + self.assertEqual(attempt_mock.call_args.kwargs.get("final_url"), cdn) + self.assertFalse(attempt_mock.call_args.kwargs.get("rewrite_plain_get")) + def test_reused_session_serves_requested_timestamp_not_stored_anchor(self): - # Field bug: rewind to X, play, then FF — TiviMate keeps the + # Field bug: rewind to X, play, then FF. Some clients keep the # ?session_id= query when rebuilding the seek URL with a new start, # and the reused session replayed the STORED position, snapping # playback back to the rewind anchor X. The reuse path must always @@ -1998,7 +2565,7 @@ class TimeshiftSessionReuseTests(TestCase): "2026-06-08:17-30", "8_2026-06-08-17-30", ) self.assertIs(response, ok) - # June -> CEST: 17:30 UTC reaches the provider as 19:30 local — never + # June -> CEST: 17:30 UTC reaches the provider as 19:30 local, never # the session's stored 19:00 anchor. self.assertEqual( attempt_mock.call_args.kwargs["provider_timestamp"], @@ -2023,22 +2590,27 @@ class TimeshiftSessionReuseTests(TestCase): ) def test_position_move_drops_previous_byte_state(self): - # content_length / serving_range describe ONE provider file; carrying - # them across a seek would feed the near-EOF/displacement heuristics - # another programme's size. A same-position update must keep them. + # content_length / serving_range / final_url describe ONE provider file; + # carrying them across a seek would feed near-EOF heuristics another + # programme's size and hit the wrong CDN token. Same-position keeps them. _seed_pool_session(self.redis, session_id=self.SESSION) self.redis.hset(self._pool_key(), mapping={ - "content_length": "2000000000", "serving_range": "start", + "content_length": "2000000000", + "serving_range": "start", + "final_url": "http://cdn.example/old.ts", }) self._call_reused_session( "2026-06-08:17-30", "8_2026-06-08-17-30", ) self.assertIsNone(self.redis.hget(self._pool_key(), "content_length")) self.assertIsNone(self.redis.hget(self._pool_key(), "serving_range")) + self.assertIsNone(self.redis.hget(self._pool_key(), "final_url")) # Same-position call (media unchanged): byte state survives. self.redis.hset(self._pool_key(), mapping={ - "content_length": "1000000", "serving_range": "range", + "content_length": "1000000", + "serving_range": "range", + "final_url": "http://cdn.example/same.ts", }) self._call_reused_session( "2026-06-08:17-30", "8_2026-06-08-17-30", @@ -2046,6 +2618,10 @@ class TimeshiftSessionReuseTests(TestCase): self.assertEqual( self.redis.hget(self._pool_key(), "content_length"), "1000000", ) + self.assertEqual( + self.redis.hget(self._pool_key(), "final_url"), + "http://cdn.example/same.ts", + ) def test_position_update_never_resurrects_vanished_entry(self): # If the pool key expired/vanished mid-request, writing to it would @@ -2106,7 +2682,7 @@ class TimeshiftSessionReuseTests(TestCase): views._release_pool_session(self.redis, TEST_SESSION_ID, 31) request = self.factory.get( - f"/timeshift/u/p/8/2026-06-08:17-30/8.ts?session_id={TEST_SESSION_ID}" + f"/timeshift/u/p/40/2026-06-08:17-30/8.ts?session_id={TEST_SESSION_ID}" ) profile = MagicMock(id=31, custom_properties={}) tz_profile = MagicMock( @@ -2122,7 +2698,7 @@ class TimeshiftSessionReuseTests(TestCase): patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream( account_id=1, stream_id="111", profile_id=31)]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", @@ -2139,7 +2715,7 @@ class TimeshiftSessionReuseTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-30", "8.ts", + request, "u", "p", "40", "2026-06-08:17-30", "8.ts", ) self.assertIs(response, ok) attempt_mock.assert_called_once() @@ -2167,13 +2743,13 @@ class TimeshiftSessionRedirectTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "RedisClient") as redis_cls: redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 301) self.assertIn("session_id=", response["Location"]) @@ -2203,7 +2779,7 @@ class TimeshiftSessionRedirectTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ @@ -2215,7 +2791,7 @@ class TimeshiftSessionRedirectTests(TestCase): redis_cls.get_client.return_value = redis channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) reacquire_mock.assert_called_once() @@ -2240,7 +2816,7 @@ class TimeshiftSessionRedirectTests(TestCase): str(time.time() - 10.0), ) request = self.factory.get( - "/timeshift/u/p/8/2026-06-08:17-30/8.ts", + "/timeshift/u/p/40/2026-06-08:17-30/8.ts", HTTP_USER_AGENT="vlc-test", REMOTE_ADDR="127.0.0.1", ) @@ -2253,7 +2829,7 @@ class TimeshiftSessionRedirectTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ @@ -2265,7 +2841,7 @@ class TimeshiftSessionRedirectTests(TestCase): redis_cls.get_client.return_value = redis channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-30", "8.ts", + request, "u", "p", "40", "2026-06-08:17-30", "8.ts", ) self.assertIs(response, ok) reacquire_mock.assert_called_once() @@ -2273,7 +2849,7 @@ class TimeshiftSessionRedirectTests(TestCase): def test_redirect_preserves_existing_query_params(self): request = self.factory.get( - "/timeshift/u/p/8/2026-06-08:17-00/8.ts?foo=bar&baz=1", + "/timeshift/u/p/40/2026-06-08:17-00/8.ts?foo=bar&baz=1", ) with patch.object(views, "_authenticate_user", return_value=MagicMock(id=1)), \ patch.object(views, "network_access_allowed", return_value=True), \ @@ -2281,13 +2857,13 @@ class TimeshiftSessionRedirectTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "RedisClient") as redis_cls: redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 301) location = response["Location"] @@ -2304,13 +2880,13 @@ class TimeshiftSessionRedirectTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "RedisClient") as redis_cls: redis_cls.get_client.return_value = _FakeRedis() channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 301) mock_close.assert_called_once() @@ -2458,7 +3034,7 @@ class FakeRedisScanTests(TestCase): class TimeshiftRangeClassificationTests(TestCase): - """Startup probes must not be treated as scrubs.""" + """Range classification and presentation helpers.""" def test_full_file_request_is_not_displacing(self): self.assertFalse(views._should_displace_busy_playback(None)) @@ -2469,6 +3045,7 @@ class TimeshiftRangeClassificationTests(TestCase): ) def test_bytes_zero_does_not_displace_active_start_stream(self): + # Scrub rule still false; residual same-session path preempts at serve time. self.assertFalse( views._should_displace_busy_playback("bytes=0-", busy_serving_range="start") ) @@ -2480,6 +3057,83 @@ class TimeshiftRangeClassificationTests(TestCase): self.assertTrue(views._is_near_eof_probe("bytes=2527702896-")) self.assertFalse(views._should_displace_busy_playback("bytes=2527702896-")) + def test_cap_open_ended_range_limits_probe_span(self): + self.assertEqual( + views._cap_open_ended_range("bytes=1000-", 100), + "bytes=1000-1099", + ) + self.assertEqual( + views._cap_open_ended_range("bytes=1000-2000", 100), + "bytes=1000-2000", + ) + + def test_resolve_session_archive_scrub_maps_ff_offset(self): + scrub = views._resolve_session_archive_scrub( + { + "final_url": "http://cdn/x", + "content_length": "1800000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "3600", + }, + "2026-06-08:17-30", + ) + self.assertEqual(scrub["kind"], "scrub") + # 30 minutes into 60 minutes ≈ half file, aligned to 188-byte TS packets. + self.assertEqual(scrub["byte_offset"] % 188, 0) + self.assertAlmostEqual(scrub["byte_offset"] / 1800000000, 0.5, places=2) + self.assertEqual(scrub["remaining"], 1800000000 - scrub["byte_offset"]) + + def test_resolve_session_archive_scrub_rejects_outside_window(self): + self.assertIsNone( + views._resolve_session_archive_scrub( + { + "final_url": "http://cdn/x", + "content_length": "1800000000", + "archive_anchor_ts": "2026-06-08:17-00", + "archive_duration_secs": "1800", + }, + "2026-06-08:17-40", + ) + ) + + def test_map_client_range_through_presentation(self): + self.assertEqual( + views._map_client_range_through_presentation( + "bytes=419800872-", 314934968, + ), + "bytes=734735840-", + ) + self.assertEqual( + views._map_client_range_through_presentation( + "bytes=100-200", 1000, + ), + "bytes=1100-1200", + ) + + def test_presentation_relative_content_range(self): + self.assertEqual( + views._presentation_relative_content_range( + "bytes 314934968-734848639/734848640", + presentation_byte_base=314934968, + presentation_length=419913672, + ), + "bytes 0-419913671/419913672", + ) + + def test_near_eof_probe_uses_presentation_length_not_full_archive(self): + # After scrub rewrite CL≈420MB; Range near that end must not displace + # (XC parallel probe) even though the offset is mid-file on the CDN archive. + self.assertTrue( + views._is_near_eof_probe( + "bytes=419800872-", content_length="419913672", + ) + ) + self.assertFalse( + views._should_displace_busy_playback( + "bytes=419800872-", content_length="419913672", + ) + ) + def test_near_eof_probe_uses_cached_content_length(self): # 5 MB into a 10 MB file is a scrub, not a tail probe. self.assertFalse( @@ -2488,10 +3142,21 @@ class TimeshiftRangeClassificationTests(TestCase): self.assertTrue( views._should_displace_busy_playback("bytes=5000000-", content_length="10000000") ) - # Within 512 KB of EOF is a tail probe once length is known. + # Within 2 MiB of EOF (incl. common 1.88MB / 10000-packet probes). self.assertTrue( views._is_near_eof_probe("bytes=9990000-", content_length="10000000") ) + total = 8_783_238_116 + self.assertTrue( + views._is_near_eof_probe( + f"bytes={total - 1_880_000}-", content_length=str(total), + ) + ) + self.assertFalse( + views._should_displace_busy_playback( + f"bytes={total - 1_880_000}-", content_length=str(total), + ) + ) def test_midfile_seek_is_displacing(self): self.assertTrue(views._should_displace_busy_playback("bytes=5000000-")) @@ -2545,6 +3210,31 @@ class TimeshiftRangeClassificationTests(TestCase): ), ) + def test_active_playback_seek_always_preempts(self): + # Heartbeats keep last_activity fresh; seek during play must still preempt. + redis = _FakeRedis() + _seed_pool_session( + redis, session_id=TEST_SESSION_ID, + media_id="8_2026-06-08-17-00", + ) + redis.hset( + views._pool_key(TEST_SESSION_ID), + "last_activity", + str(views.time.time()), + ) + user = MagicMock(id=5) + with patch.object( + views, "_session_has_active_timeshift_stream", return_value=True, + ): + self.assertTrue( + views._should_preempt_for_programme_change( + redis, TEST_SESSION_ID, + "8_2026-06-08-17-00", + "8_2026-06-08-17-30", + user=user, + ), + ) + class TimeshiftStatsClientTests(TestCase): def setUp(self): @@ -3925,7 +4615,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ @@ -3942,16 +4632,13 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) - reacquire_mock.assert_called_once_with( - self.redis, existing, user_id=5, user=self.user, - wait_seconds=views._POOL_SCRUB_WAIT_SECONDS, - ) + reacquire_mock.assert_called_once() def test_plain_reconnect_preempts_busy_pool_without_range(self): - """TiviMate FF reconnects with plain GET; match provider byte-0 restart.""" + """Plain GET reconnect should match provider byte-0 restart.""" _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) request = self.factory.get(_proxy_url(TEST_SESSION_ID)) streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] @@ -3963,7 +4650,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ @@ -3981,7 +4668,7 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) reacquire_mock.assert_called_once() @@ -4004,7 +4691,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ @@ -4023,7 +4710,7 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) reacquire_mock.assert_called_once() @@ -4052,7 +4739,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)) as reserve_mock, \ @@ -4070,7 +4757,7 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) reacquire_mock.assert_called_once() @@ -4094,7 +4781,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ @@ -4108,13 +4795,14 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 503) preempt_mock.assert_not_called() attempt_mock.assert_not_called() def test_eof_probe_deferred_without_preempt(self): + """Near-EOF probes without a cached CDN URL still get 503 (no preempt).""" _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) request = self.factory.get( _proxy_url(TEST_SESSION_ID), @@ -4126,25 +4814,240 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ patch.object(views, "release_profile_slot"), \ patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_try_reacquire_idle_pool") as reacquire_mock, \ patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_open_upstream") as open_mock, \ patch.object(views, "_attempt_timeshift_stream") as attempt_mock: redis_cls.get_client.return_value = self.redis channel_cls.objects.get.return_value = MagicMock( id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 503) + reacquire_mock.assert_not_called() + preempt_mock.assert_not_called() + open_mock.assert_not_called() + attempt_mock.assert_not_called() + + def test_eof_probe_busy_session_serves_cached_cdn_without_preempt(self): + """Busy near-EOF duration probes answer from cached CDN, no slot churn.""" + presentation_base = 500_000_000 + presentation_length = 615_251_824 + client_start = 615_139_024 + cdn_start = presentation_base + client_start + body = b"probe-tail" + cdn_end = cdn_start + len(body) - 1 + archive_total = 2_527_702_896 + + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + pool_key = TimeshiftRedisKeys.pool(TEST_SESSION_ID) + self.redis.hset(pool_key, mapping={ + "final_url": "http://cdn.example.test/archive.ts", + "content_length": str(archive_total), + "presentation_byte_base": str(presentation_base), + "presentation_length": str(presentation_length), + "provider_user_agent": "provider-agent", + "busy": "1", + }) + + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE=f"bytes={client_start}-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + upstream = _fake_upstream(206, body=body) + upstream.headers["Content-Range"] = ( + f"bytes {cdn_start}-{cdn_end}/{archive_total}" + ) + upstream.headers["Content-Length"] = str(len(body)) + upstream.raw.read = MagicMock(side_effect=[body, b""]) + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot") as reserve_mock, \ + patch.object(views, "release_profile_slot") as release_mock, \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_try_reacquire_idle_pool") as reacquire_mock, \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_register_stats_client") as stats_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock, \ + patch.object(views.M3UAccount.objects, "get") as account_get_mock, \ + patch.object(views, "_open_upstream", return_value=upstream) as open_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", + ) + + self.assertEqual(response.status_code, 206) + self.assertEqual( + response["Content-Range"], + f"bytes {client_start}-{client_start + len(body) - 1}/{presentation_length}", + ) + self.assertEqual(response["Content-Length"], str(len(body))) + open_mock.assert_called_once() + open_args, open_kwargs = open_mock.call_args + self.assertEqual(open_args[0], "http://cdn.example.test/archive.ts") + self.assertEqual(open_args[1], "provider-agent") + self.assertEqual( + open_args[2], + f"bytes={cdn_start}-{cdn_start + views._EOF_PROBE_TAIL_BYTES - 1}", + ) + self.assertFalse(open_kwargs.get("allow_redirects", True)) + account_get_mock.assert_not_called() + reacquire_mock.assert_not_called() preempt_mock.assert_not_called() attempt_mock.assert_not_called() + stats_mock.assert_not_called() + reserve_mock.assert_not_called() + release_mock.assert_not_called() + self.assertEqual(self.redis.hget(pool_key, "busy"), "1") + # Drain the short probe body so the generator finishes cleanly. + self.assertEqual(b"".join(response.streaming_content), body) + upstream.close.assert_called() + + def test_eof_probe_stale_presentation_base_uses_absolute_range(self): + """After return-to-start, archive-absolute EOF probes must not remap past EOF.""" + stale_base = 348_248_380 + archive_total = 870_621_184 + client_start = archive_total - 112_800 + body = b"probe-tail" + + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + pool_key = TimeshiftRedisKeys.pool(TEST_SESSION_ID) + self.redis.hset(pool_key, mapping={ + "final_url": "http://cdn.example.test/archive.ts", + "content_length": str(archive_total), + # Stale scrub window left behind after return-to-start. + "presentation_byte_base": str(stale_base), + "presentation_length": str(archive_total), + "provider_user_agent": "provider-agent", + "busy": "1", + }) + + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE=f"bytes={client_start}-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + upstream = _fake_upstream(206, body=body) + upstream.headers["Content-Range"] = ( + f"bytes {client_start}-{client_start + len(body) - 1}/{archive_total}" + ) + upstream.headers["Content-Length"] = str(len(body)) + upstream.raw.read = MagicMock(side_effect=[body, b""]) + + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot"), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_try_reacquire_idle_pool") as reacquire_mock, \ + patch.object(views, "_preempt_playback_streams") as preempt_mock, \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock, \ + patch.object(views.M3UAccount.objects, "get") as account_get_mock, \ + patch.object(views, "_open_upstream", return_value=upstream) as open_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", + ) + + self.assertEqual(response.status_code, 206) + open_args, _open_kwargs = open_mock.call_args + # Must NOT be stale_base + client_start (that is past archive EOF). + self.assertEqual(open_args[1], "provider-agent") + self.assertEqual( + open_args[2], + f"bytes={client_start}-{client_start + views._EOF_PROBE_TAIL_BYTES - 1}", + ) + account_get_mock.assert_not_called() + reacquire_mock.assert_not_called() + preempt_mock.assert_not_called() + attempt_mock.assert_not_called() + self.assertEqual(b"".join(response.streaming_content), body) + + def test_eof_probe_cdn_416_returns_416_not_503(self): + """Unsatisfiable EOF probes must not fall through to busy-slot 503.""" + archive_total = 870_621_184 + client_start = archive_total - 112_800 + + _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) + pool_key = TimeshiftRedisKeys.pool(TEST_SESSION_ID) + self.redis.hset(pool_key, mapping={ + "final_url": "http://cdn.example.test/archive.ts", + "content_length": str(archive_total), + "presentation_byte_base": "0", + "presentation_length": str(archive_total), + "provider_user_agent": "provider-agent", + "busy": "1", + }) + + request = self.factory.get( + _proxy_url(TEST_SESSION_ID), + HTTP_RANGE=f"bytes={client_start}-", + ) + streams = [_make_catchup_stream(account_id=1, stream_id="111", profile_id=31)] + upstream = MagicMock() + upstream.status_code = 416 + upstream.headers = {} + upstream.close = MagicMock() + + with patch.object(views, "_authenticate_user", return_value=MagicMock(id=5)), \ + patch.object(views, "network_access_allowed", return_value=True), \ + patch.object(views, "Channel") as channel_cls, \ + patch.object(views, "_user_can_access_channel", return_value=True), \ + patch.object(views, "get_channel_catchup_streams", return_value=streams), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ + patch.object(views, "check_user_stream_limits", return_value=True), \ + patch.object(views, "RedisClient") as redis_cls, \ + patch.object(views, "reserve_profile_slot"), \ + patch.object(views, "release_profile_slot"), \ + patch.object(views, "get_transformed_credentials", side_effect=_fake_creds), \ + patch.object(views, "get_user_active_connections", return_value=[]), \ + patch.object(views, "_attempt_timeshift_stream") as attempt_mock, \ + patch.object(views.M3UAccount.objects, "get") as account_get_mock, \ + patch.object(views, "_open_upstream", return_value=upstream) as open_mock: + redis_cls.get_client.return_value = self.redis + channel_cls.objects.get.return_value = MagicMock( + id=8, name="Test", logo_id=None, + ) + response = views.timeshift_proxy( + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", + ) + + self.assertEqual(response.status_code, 416) + self.assertEqual(response["Content-Range"], f"bytes */{archive_total}") + self.assertEqual(open_mock.call_args.args[1], "provider-agent") + account_get_mock.assert_not_called() + attempt_mock.assert_not_called() + upstream.close.assert_called() def test_scrub_opens_failover_when_pool_still_busy(self): _seed_pool_session(self.redis, session_id=TEST_SESSION_ID) @@ -4159,7 +5062,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", return_value=(True, 1, None)), \ @@ -4173,7 +5076,7 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) reacquire_mock.assert_called_once() @@ -4301,7 +5204,7 @@ class TimeshiftScrubPreemptTests(TestCase): patch.object(views, "Channel") as channel_cls, \ patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=streams), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "check_user_stream_limits", return_value=True), \ patch.object(views, "RedisClient") as redis_cls, \ patch.object(views, "reserve_profile_slot", @@ -4319,7 +5222,7 @@ class TimeshiftScrubPreemptTests(TestCase): id=8, name="Test", logo_id=None, ) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertIs(response, ok) preempt_mock.assert_not_called() @@ -4368,7 +5271,7 @@ class CatchupProxyTests(TestCase): patch.object(views, "_user_can_access_channel", return_value=True), \ patch.object(views, "get_channel_catchup_streams", return_value=[_make_catchup_stream()]), \ - patch.object(views, "get_programme_duration", return_value=40), \ + patch.object(views, "resolve_catchup_duration", return_value=40), \ patch.object(views, "parse_catchup_timestamp", return_value=True), \ patch.object(views, "RedisClient") as redis_cls: redis_cls.get_client.return_value = _FakeRedis() @@ -4387,14 +5290,15 @@ class CatchupProxyTests(TestCase): patch.object(views, "_serve_catchup", return_value=HttpResponse("ok")) as serve: channel_cls.objects.get.return_value = MagicMock(id=8) response = views.timeshift_proxy( - request, "u", "p", "8", "2026-06-08:17-00", "8.ts", + request, "u", "p", "40", "2026-06-08:17-00", "8.ts", ) self.assertEqual(response.status_code, 200) serve.assert_called_once() + self.assertEqual(serve.call_args.kwargs.get("client_duration_hint"), "40") class TimeshiftProviderFaithfulPlainGetTests(_ProxyLoopTestMixin, TestCase): - """Contract tests for TiviMate-style plain GET reconnect (no Range header).""" + """Contract tests for plain GET reconnect (no Range header).""" def setUp(self): self.redis = _FakeRedis() @@ -4417,7 +5321,7 @@ class TimeshiftProviderFaithfulPlainGetTests(_ProxyLoopTestMixin, TestCase): stats_channel_id=self.stats_channel_id, client_id=self.client_id, client_ip="1.2.3.4", - client_user_agent="tivimate", + client_user_agent="test-agent", user=self.user, channel_display_name="A&E", timestamp_utc="2026-06-08:17-00", @@ -4453,7 +5357,7 @@ class TimeshiftProviderFaithfulPlainGetTests(_ProxyLoopTestMixin, TestCase): stats_channel_id=self.stats_channel_id, client_id=self.client_id, client_ip="1.2.3.4", - client_user_agent="tivimate", + client_user_agent="test-agent", user=self.user, channel_display_name="A&E", timestamp_utc="2026-06-08:17-00", diff --git a/apps/timeshift/views.py b/apps/timeshift/views.py index 71717e36..b84e0c36 100644 --- a/apps/timeshift/views.py +++ b/apps/timeshift/views.py @@ -58,11 +58,16 @@ from .helpers import ( TimeshiftCredentials, build_timeshift_candidate_urls, convert_timestamp_to_provider_tz, - get_programme_duration, + order_catchup_streams_for_timestamp, parse_catchup_timestamp, + resolve_catchup_duration, ) from .sessions import catchup_session_exists, delete_catchup_session, resolve_catchup_playback -from .stats import resolve_stats_playback_fields, seed_stream_stats_metadata +from .stats import ( + EOF_PROBE_TAIL_BYTES, + resolve_stats_playback_fields, + seed_stream_stats_metadata, +) logger = logging.getLogger(__name__) @@ -95,14 +100,15 @@ def _finalize_timeshift_response(response): return response -def timeshift_proxy(request, username, password, stream_id, timestamp, duration): # noqa: ARG001 stream_id +def timeshift_proxy(request, username, password, duration, timestamp, channel_id): """Proxy an XC catch-up request to the provider with multi-stream failover. - URL shape (iPlayTV / TiviMate): - ``stream_id``: EPG channel number (ignored here). - ``duration``: Dispatcharr ``Channel.id`` (XC API exposes channel.id as stream_id). + URL shape (XC catch-up clients, matches provider PATH form): + ``/timeshift/{user}/{pass}/{duration}/{start}/{channel_id}.ts`` + ``duration``: programme length in minutes from the client's guide. ``timestamp``: UTC programme start (``YYYY-MM-DD:HH-MM`` or XC colon form ``YYYY-MM-DD:HH:MM:SS``). + ``channel_id``: Dispatcharr ``Channel.id`` (often with a ``.ts`` suffix). Session handling (``?session_id=``): First request with no ``session_id`` and no matching pool entry → ``301`` with a @@ -110,7 +116,37 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) an in-flight or idle pool entry for the same viewer are served immediately (no redirect). Reuse ``session_id`` for all range/seek requests in a programme. """ - raw_id = duration[:-3] if duration.endswith(".ts") else duration + return _timeshift_proxy_impl( + request, username, password, timestamp, channel_id, + client_duration_hint=duration, + ) + + +def timeshift_proxy_query(request): + """Proxy an XC catch-up request submitted in QUERY layout. + + URL shape (XC catch-up clients): ``/streaming/timeshift.php?username=... + &password=...&stream=&start=&duration=``. + ``duration`` is preferred over EPG when present (same as the PATH form). + """ + username = request.GET.get("username", "") + password = request.GET.get("password", "") + timestamp = request.GET.get("start", "") + channel_id = request.GET.get("stream", "") + if not (username and password and timestamp and channel_id): + return _finalize_timeshift_response( + HttpResponseBadRequest("Missing required parameters") + ) + return _timeshift_proxy_impl( + request, username, password, timestamp, channel_id, + client_duration_hint=request.GET.get("duration"), + ) + + +def _timeshift_proxy_impl( + request, username, password, timestamp, channel_id, client_duration_hint=None, +): + raw_id = channel_id[:-3] if channel_id.endswith(".ts") else channel_id user = _authenticate_user(username, password) if user is None: @@ -128,7 +164,10 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) if not _user_can_access_channel(user, channel): return _finalize_timeshift_response(HttpResponseForbidden("Access denied")) - return _serve_catchup(request, user, channel, timestamp) + return _serve_catchup( + request, user, channel, timestamp, + client_duration_hint=client_duration_hint, + ) @extend_schema( @@ -151,7 +190,7 @@ def timeshift_proxy(request, username, password, stream_id, timestamp, duration) "redirect round trip.\n\n" "**Plain GET (no ``Range``):** upstream archive is streamed from byte " "0 with ``Content-Length`` when the provider reports file size " - "(provider-faithful behaviour for clients such as TiviMate)." + "(provider-faithful behaviour for XC-style IPTV clients)." ), parameters=[ OpenApiParameter( @@ -226,6 +265,8 @@ def catchup_proxy(request, channel_id): session_id = request.GET.get("session_id") timestamp = request.GET.get("start") user = auth_user + # Direct-auth clients may pass ?duration=; API sessions store their own. + client_duration_hint = request.GET.get("duration") if session_id: resolved = resolve_catchup_playback(session_id, channel_id) @@ -236,11 +277,13 @@ def catchup_proxy(request, channel_id): status=401, ) else: - session_user, bound_start = resolved + session_user, bound_start, bound_duration = resolved if auth_user is not None and auth_user.id != session_user.id: return _finalize_timeshift_response(HttpResponseForbidden("Access denied")) user = session_user timestamp = bound_start + if bound_duration is not None: + client_duration_hint = bound_duration if user is None: return JsonResponse({"error": "Authentication required"}, status=401) @@ -257,11 +300,21 @@ def catchup_proxy(request, channel_id): if not timestamp: return _finalize_timeshift_response(HttpResponseBadRequest("Missing start parameter")) - return _serve_catchup(request, user, channel, timestamp) + return _serve_catchup( + request, user, channel, timestamp, + client_duration_hint=client_duration_hint, + ) -def _serve_catchup(request, user, channel, timestamp): - """Shared catch-up proxy logic for XC and native API entry points.""" +def _serve_catchup(request, user, channel, timestamp, client_duration_hint=None): + """Shared catch-up proxy logic for XC and native API entry points. + + ``client_duration_hint`` is a programme length in minutes supplied by the + client (XC PATH duration segment, QUERY ``duration=``, or a native + session's stored duration). When usable it is preferred over EPG; + otherwise EPG is used, falling back to ``DEFAULT_DURATION_MINUTES``. + A ``DURATION_BUFFER_MINUTES`` pad is applied either way for provider lag. + """ if parse_catchup_timestamp(timestamp) is None: return _finalize_timeshift_response(HttpResponseBadRequest("Invalid timestamp")) @@ -271,10 +324,15 @@ def _serve_catchup(request, user, channel, timestamp): HttpResponseBadRequest("Timeshift not supported for this channel") ) + catchup_streams = order_catchup_streams_for_timestamp(catchup_streams, timestamp) + debug = logger.isEnabledFor(logging.DEBUG) - # EPG duration lookup stays in UTC; provider TZ conversion is per-attempt below. - duration_minutes = get_programme_duration(channel, timestamp) + # Client hint preferred; else EPG (UTC); else DEFAULT_DURATION_MINUTES. + # Provider TZ conversion for the start timestamp is per-attempt below. + duration_minutes = resolve_catchup_duration( + channel, timestamp, client_hint=client_duration_hint, + ) safe_ts = timestamp.replace(":", "-").replace("/", "-") client_ip = get_client_ip(request) @@ -377,15 +435,22 @@ def _serve_catchup(request, user, channel, timestamp): pool_content_length = pool["content_length"] if pool else None busy_serving_range = pool["serving_range"] if pool else None pool_media_id = None + pool_presentation_length = None if pool and pool.get("entry"): pool_media_id = pool["entry"].get("media_id") + pool_presentation_length = pool["entry"].get("presentation_length") elif session_entry: pool_media_id = session_entry.get("media_id") + pool_presentation_length = session_entry.get("presentation_length") + # After a scrub rewrite the client sees a shorter file; EOF probes and + # Range seeks are relative to that presentation, not the full CDN archive. + probe_length = pool_presentation_length or pool_content_length + scrub_displacement = ( pool_exists and _should_displace_busy_pool( range_header, - pool_content_length, + probe_length, busy_serving_range, pool_media_id=pool_media_id, media_id=media_id, @@ -407,6 +472,7 @@ def _serve_catchup(request, user, channel, timestamp): ) elif _should_preempt_for_programme_change( redis_client, effective_session_id, pool_media_id, media_id, + user=user, ): acquired = _try_reacquire_idle_pool( redis_client, effective_session_id, @@ -482,6 +548,21 @@ def _serve_catchup(request, user, channel, timestamp): pass if pool_exists and pool_busy and acquired is None: + probe_entry = None + if pool and pool.get("entry"): + probe_entry = pool["entry"] + elif session_entry: + probe_entry = session_entry + probe_response = _try_serve_busy_eof_probe( + redis_client=redis_client, + session_id=effective_session_id, + entry=probe_entry, + range_header=range_header, + probe_length=probe_length, + debug=debug, + ) + if probe_response is not None: + return probe_response logger.debug( "Timeshift: deferring busy session %s range=%s media=%s pool_media=%s", effective_session_id, range_header or "(none)", media_id, pool_media_id, @@ -675,7 +756,8 @@ _POOL_WAIT_SECONDS = 1.0 # Brief wait after scrub preempt before failover. _POOL_SCRUB_WAIT_SECONDS = 2.0 _POOL_POLL_INTERVAL = 0.05 -_EOF_PROBE_TAIL_BYTES = 512_000 +# Near-EOF duration probes (~10000 MPEG-TS packets / 1.88MB). Shared with stats. +_EOF_PROBE_TAIL_BYTES = EOF_PROBE_TAIL_BYTES _EOF_PROBE_UNKNOWN_LENGTH_MIN = 100_000_000 @@ -1135,7 +1217,7 @@ def _build_downstream_length_headers( headers["Content-Length"] = str(up_end - up_start + 1) return headers - # Plain GET streaming 200: match provider/CDN Content-Length (TiviMate uses + # Plain GET streaming 200: match provider/CDN Content-Length (clients use # it for archive duration; omitting it breaks FF reconnect playback). if streaming and status_code == 200 and not range_header: if representation_length is not None: @@ -1187,18 +1269,26 @@ def _should_displace_busy_pool( def _should_preempt_for_programme_change( - redis_client, session_id, pool_media_id, media_id, + redis_client, session_id, pool_media_id, media_id, *, user=None, ): - """True when the viewer moved to a different programme on the same session.""" + """True when the viewer moved to a different start on the same session. + + XC clients often rebuild the catch-up URL with a new ``start`` while keeping + ``session_id``. During active playback that must always preempt the + in-flight stream (heartbeats keep ``last_activity`` fresh, so the old ~2s + startup-probe window was returning 503 on every seek). + """ if pool_media_id is None or str(pool_media_id) == str(media_id): return False + if user is not None and _session_has_active_timeshift_stream(user, session_id): + return True try: last_activity = float( _get_pool_entry(redis_client, session_id).get("last_activity") or 0 ) except (TypeError, ValueError): last_activity = 0 - # Ignore parallel startup probes within ~2s. + # Parallel startup probes with no live stream yet: ignore brief media churn. return (time.time() - last_activity) >= _STATS_GRACE_MIN_ELAPSED_SECONDS @@ -1224,9 +1314,9 @@ def _should_preempt_plain_reconnect( ): """True when a plain GET should restart playback like the provider does. - TiviMate fast-forward closes the HTTP connection and reopens the same - programme URL without a Range header. Providers answer that with a full - 200 from byte 0, not an internal byte-range resume. + IPTV clients often close the HTTP connection and reopen the same programme + URL without a Range header. Providers answer + that with a full 200 from byte 0, not an internal byte-range resume. """ if range_header: return False @@ -1254,6 +1344,189 @@ def _should_displace_busy_playback( return True +def _cap_open_ended_range(range_header, max_span_bytes): + """Limit an open-ended ``bytes=START-`` Range to at most ``max_span_bytes``.""" + parsed = _parse_client_range(range_header) + if parsed is None: + return range_header + start, end = parsed + if end is not None or max_span_bytes is None or max_span_bytes <= 0: + return range_header + return f"bytes={start}-{start + int(max_span_bytes) - 1}" + + +def _try_serve_busy_eof_probe( + *, + redis_client, + session_id, + entry, + range_header, + probe_length, + debug=False, +): + """Serve a near-EOF duration probe via cached CDN without preempting playback. + + Returns a response, or ``None`` to fall through to busy ``503``. + No pool/stats/profile side effects. Caps open-ended Ranges so a probe + cannot pull the remainder of a multi-GB archive. + """ + if not entry or not range_header: + return None + if not _is_near_eof_probe(range_header, probe_length): + return None + + final_url = entry.get("final_url") + if isinstance(final_url, bytes): + final_url = final_url.decode() + if not final_url: + return None + + content_length = _pool_int_field(entry.get("content_length")) + presentation_base = _pool_int_field(entry.get("presentation_byte_base")) + presentation_length = _pool_int_field(entry.get("presentation_length")) + effective_range = range_header + relative_presentation = False + if presentation_base and presentation_base > 0: + mapped_range = _map_client_range_through_presentation( + range_header, presentation_base, + ) + mapped_start = _parse_range_start(mapped_range) + client_start = _parse_range_start(range_header) + # Stale scrub base: client Range is already archive-absolute. + if ( + content_length is not None + and mapped_start is not None + and mapped_start >= content_length + and client_start is not None + and client_start < content_length + ): + effective_range = range_header + relative_presentation = False + presentation_length = content_length + else: + effective_range = mapped_range + relative_presentation = True + + effective_range = _cap_open_ended_range(effective_range, _EOF_PROBE_TAIL_BYTES) + + user_agent = entry.get("provider_user_agent") or "" + if isinstance(user_agent, bytes): + user_agent = user_agent.decode() + + try: + upstream = _open_upstream( + final_url, user_agent, effective_range, allow_redirects=False, + ) + except Exception as exc: + logger.debug( + "Timeshift EOF probe CDN open failed session=%s: %s", + session_id, exc, + ) + return None + + if upstream.status_code == 416: + try: + upstream.close() + except Exception: + pass + total = ( + presentation_length + if relative_presentation and presentation_length + else content_length + ) + if total is None: + total = _pool_int_field(probe_length) + response = HttpResponse(status=416) + response["Accept-Ranges"] = "bytes" + if total is not None: + response["Content-Range"] = f"bytes */{int(total)}" + if debug: + logger.debug( + "Timeshift EOF probe 416: session=%s client_range=%s cdn_range=%s", + session_id, range_header, effective_range, + ) + return _finalize_timeshift_response(response) + + if upstream.status_code not in (200, 206): + try: + upstream.close() + except Exception: + pass + logger.debug( + "Timeshift EOF probe CDN rejected session=%s status=%s", + session_id, getattr(upstream, "status_code", None), + ) + return None + + content_type = upstream.headers.get("Content-Type", "video/mp2t") + status = upstream.status_code + content_range = upstream.headers.get("Content-Range", "") or None + if relative_presentation and content_range and presentation_length is not None: + content_range = _presentation_relative_content_range( + content_range, + presentation_byte_base=presentation_base, + presentation_length=presentation_length, + ) + + client_headers = _build_downstream_length_headers( + range_header=None if relative_presentation else range_header, + status_code=status, + representation_length=( + presentation_length + if relative_presentation and presentation_length is not None + else _extract_representation_length(upstream) + ), + upstream_content_range=content_range, + upstream_content_length=upstream.headers.get("Content-Length"), + streaming=True, + ) + + chunk_size = max(ConfigHelper.chunk_size(), 262144) + closed = {"done": False} + + def _finish(): + if closed["done"]: + return + closed["done"] = True + try: + upstream.close() + except Exception: + pass + + def _generator(): + try: + while True: + try: + chunk = upstream.raw.read(chunk_size) + except Exception: + break + if not chunk: + break + yield chunk + except GeneratorExit: + pass + finally: + _finish() + + if debug: + logger.debug( + "Timeshift EOF probe pass-through: session=%s client_range=%s " + "cdn_range=%s status=%d", + session_id, range_header, effective_range, status, + ) + + stream_iter = _SlotReleasingStream(_generator(), _finish) + response = StreamingHttpResponse( + stream_iter, + content_type=content_type, + status=status, + ) + response["X-Accel-Buffering"] = "no" + for header_name, header_value in client_headers.items(): + response[header_name] = header_value + return _finalize_timeshift_response(response) + + def _score_pool_fingerprint(entry, client_ip, client_user_agent): """Score IP/UA overlap for fingerprint adoption (user and media pre-filtered).""" score = 0 @@ -1402,8 +1675,15 @@ def _store_pool_serving_range(redis_client, session_id, range_header): logger.debug("Timeshift pool serving_range store failed: %s", exc) -def _update_pool_position(redis_client, session_id, *, media_id, provider_timestamp): - """Move a reused session's descriptor to the position actually served.""" +def _update_pool_position( + redis_client, session_id, *, media_id, provider_timestamp, keep_archive=False, +): + """Move a reused session's descriptor to the position actually served. + + XC FF/RW rebuilds the catch-up URL with a new *start* timestamp (same + session). That changes ``media_id`` but is still the same opened CDN + archive; keep ``final_url`` / sizes when *keep_archive* is True. + """ if redis_client is None or not session_id: return key = _pool_key(session_id) @@ -1419,8 +1699,18 @@ def _update_pool_position(redis_client, session_id, *, media_id, provider_timest "media_id": str(media_id), "provider_timestamp": str(provider_timestamp), }) - if position_changed: - redis_client.hdel(key, "content_length", "serving_range") + if position_changed and not keep_archive: + # Left the opened archive window; drop file-specific state. + redis_client.hdel( + key, + "content_length", + "serving_range", + "final_url", + "archive_anchor_ts", + "archive_duration_secs", + "presentation_length", + "presentation_byte_base", + ) except Exception as exc: logger.debug("Timeshift pool position update failed: %s", exc) @@ -1439,6 +1729,178 @@ def _store_pool_content_length(redis_client, session_id, upstream_response): logger.debug("Timeshift pool content_length store failed: %s", exc) +def _store_pool_final_url(redis_client, session_id, final_url): + """Persist the post-redirect CDN URL for VOD-style reconnect reuse.""" + if redis_client is None or not session_id or not final_url: + return + try: + redis_client.hset(_pool_key(session_id), "final_url", str(final_url)) + except Exception as exc: + logger.debug("Timeshift pool final_url store failed: %s", exc) + + +def _store_pool_provider_user_agent(redis_client, session_id, user_agent): + """Snapshot the resolved account User-Agent for this playback session.""" + if redis_client is None or not session_id: + return + key = _pool_key(session_id) + try: + if redis_client.exists(key): + redis_client.hset(key, "provider_user_agent", str(user_agent or "")) + except Exception as exc: + logger.debug("Timeshift pool provider User-Agent store failed: %s", exc) + + +def _clear_pool_final_url(redis_client, session_id): + if redis_client is None or not session_id: + return + try: + redis_client.hdel(_pool_key(session_id), "final_url") + except Exception as exc: + logger.debug("Timeshift pool final_url clear failed: %s", exc) + + +def _store_pool_presentation_window( + redis_client, session_id, length, *, byte_base=0, +): + """Client-visible size/origin after a scrub rewrite (relative Ranges map here).""" + if redis_client is None or not session_id or length is None: + return + try: + redis_client.hset( + _pool_key(session_id), + mapping={ + "presentation_length": str(int(length)), + "presentation_byte_base": str(int(byte_base or 0)), + }, + ) + except Exception as exc: + logger.debug("Timeshift pool presentation window store failed: %s", exc) + + +def _pool_int_field(value): + if value is None or value == "": + return None + try: + if isinstance(value, bytes): + value = value.decode() + return int(value) + except (TypeError, ValueError): + return None + + +def _map_client_range_through_presentation(range_header, presentation_byte_base): + """Translate presentation-relative Range into absolute CDN archive Range.""" + if presentation_byte_base is None or not range_header: + return range_header + parsed = _parse_client_range(range_header) + if parsed is None: + return range_header + start, end = parsed + base = int(presentation_byte_base) + abs_start = base + start + if end is None: + return f"bytes={abs_start}-" + return f"bytes={abs_start}-{base + end}" + + +def _presentation_relative_content_range( + upstream_content_range, *, presentation_byte_base, presentation_length, +): + """Rewrite absolute CDN Content-Range into presentation-relative coords.""" + if ( + not upstream_content_range + or presentation_byte_base is None + or presentation_length is None + ): + return upstream_content_range + parsed = _parse_content_range_header(upstream_content_range) + if not parsed or parsed.get("end") is None: + return upstream_content_range + base = int(presentation_byte_base) + rel_start = parsed["start"] - base + rel_end = parsed["end"] - base + if rel_start < 0 or rel_end < rel_start: + return upstream_content_range + return f"bytes {rel_start}-{rel_end}/{int(presentation_length)}" + + +def _ensure_pool_archive_anchor( + redis_client, session_id, *, timestamp, duration_minutes, force=False, +): + """Remember the CDN archive window opened for this session (first portal hit).""" + if redis_client is None or not session_id or not timestamp: + return + key = _pool_key(session_id) + try: + if not force and redis_client.hget(key, "archive_anchor_ts"): + return + duration_secs = max(int(float(duration_minutes or 0) * 60), 60) + redis_client.hset(key, mapping={ + "archive_anchor_ts": str(timestamp), + "archive_duration_secs": str(duration_secs), + }) + except Exception as exc: + logger.debug("Timeshift pool archive anchor store failed: %s", exc) + + +def _resolve_session_archive_scrub(descriptor, requested_timestamp): + """Map an in-session timestamp rebuild onto the open CDN archive. + + XC clients often rebuild ``/timeshift/...//...`` while keeping + ``session_id`` instead of Range-seeking. Same session + in-window start + maps to a byte offset into the already-open CDN file (no portal). + + Returns ``None`` when the request is outside the opened window (new portal + required), or a dict with ``kind`` ``same`` / ``scrub``, ``byte_offset``, + and ``remaining``. + """ + if not descriptor or not requested_timestamp: + return None + final_url = descriptor.get("final_url") or "" + if isinstance(final_url, bytes): + final_url = final_url.decode() + if not final_url: + return None + try: + content_length = int(descriptor.get("content_length")) + duration_secs = float(descriptor.get("archive_duration_secs")) + except (TypeError, ValueError): + return None + if content_length <= 0 or duration_secs <= 0: + return None + anchor_raw = descriptor.get("archive_anchor_ts") + if isinstance(anchor_raw, bytes): + anchor_raw = anchor_raw.decode() + requested_dt = parse_catchup_timestamp(requested_timestamp) + anchor_dt = parse_catchup_timestamp(anchor_raw) if anchor_raw else None + if requested_dt is None or anchor_dt is None: + return None + offset_secs = (requested_dt - anchor_dt).total_seconds() + if abs(offset_secs) < 1.0: + return { + "kind": "same", + "byte_offset": 0, + "remaining": content_length, + } + # Reverse seek before the opened CDN file: content is not on this URL. + # Caller must portal/re-anchor (cannot Range-seek earlier than the open). + if offset_secs < 0: + return None + if offset_secs >= duration_secs: + return None + byte_offset = int((offset_secs / duration_secs) * content_length) + byte_offset = (byte_offset // 188) * 188 + remaining = content_length - byte_offset + if remaining <= 0: + return None + return { + "kind": "scrub", + "byte_offset": byte_offset, + "remaining": remaining, + } + + def _pool_lock(redis_client, session_id): return redis_client.lock( TimeshiftRedisKeys.pool_lock(session_id), @@ -1995,6 +2457,11 @@ def _attempt_timeshift_stream( pool_session_id=None, stats_stream_id=None, stream_stats=None, + final_url=None, + rewrite_plain_get=False, + presentation_remaining=None, + presentation_byte_base=None, + relative_presentation_range=False, ): """Build the provider URL set for one (account, profile, stream) and stream it.""" server_url, xc_username, xc_password = get_transformed_credentials( @@ -2009,6 +2476,9 @@ def _attempt_timeshift_stream( user_agent = m3u_account.get_user_agent().user_agent except AttributeError: user_agent = "" + _store_pool_provider_user_agent( + redis_client, pool_session_id, user_agent, + ) virtual_channel_id = make_virtual_channel_id( channel.id, safe_ts, stream_id_value, @@ -2018,10 +2488,12 @@ def _attempt_timeshift_stream( if debug: logger.debug( "Timeshift attempt: channel=%s ts=%s (provider tz=%s -> %s) " - "account=%s profile=%s provider_sid=%s vid=%s client=%s range=%s", + "account=%s profile=%s provider_sid=%s vid=%s client=%s range=%s " + "cdn=%s", channel.name, timestamp, provider_tz_name, provider_timestamp, m3u_account.id, profile.id, stream_id_value, virtual_channel_id, client_id, range_header or "(none)", + "cached" if final_url else "portal", ) return _stream_from_provider( @@ -2048,6 +2520,11 @@ def _attempt_timeshift_stream( stats_stream_id=stats_stream_id, stream_stats=stream_stats, duration_minutes=duration_minutes, + final_url=final_url, + rewrite_plain_get=rewrite_plain_get, + presentation_remaining=presentation_remaining, + presentation_byte_base=presentation_byte_base, + relative_presentation_range=relative_presentation_range, ) @@ -2087,11 +2564,99 @@ def _stream_reused_session( provider_tz_name = server_info.get("timezone") provider_timestamp = convert_timestamp_to_provider_tz(timestamp, provider_tz_name) - # Move the descriptor to the position actually served so fingerprint - # matching and same-channel displacement keep working after the seek. + prior_media_id = descriptor.get("media_id") + if isinstance(prior_media_id, bytes): + prior_media_id = prior_media_id.decode() + media_changed = str(prior_media_id or "") != str(media_id) + + scrub_info = _resolve_session_archive_scrub(descriptor, timestamp) + raw_final_url = descriptor.get("final_url") + if isinstance(raw_final_url, bytes): + raw_final_url = raw_final_url.decode() + rewrite_plain_get = False + presentation_remaining = None + presentation_byte_base = None + relative_presentation_range = False + effective_range = range_header + prior_presentation_base = _pool_int_field( + descriptor.get("presentation_byte_base"), + ) + prior_presentation_length = _pool_int_field( + descriptor.get("presentation_length"), + ) + + if scrub_info is not None: + # Same opened CDN archive: FF within the file opened for this session. + keep_archive = True + final_url = raw_final_url or None + if scrub_info["kind"] == "scrub" and not range_header: + effective_range = f"bytes={scrub_info['byte_offset']}-" + rewrite_plain_get = True + presentation_remaining = scrub_info["remaining"] + presentation_byte_base = scrub_info["byte_offset"] + if debug: + logger.debug( + "Timeshift session scrub: session=%s offset=%d remaining=%d " + "(%s -> %s)", + session_id, scrub_info["byte_offset"], scrub_info["remaining"], + prior_media_id, media_id, + ) + elif scrub_info["kind"] == "same": + # Back at archive open: reset scrub window so Ranges stay absolute. + presentation_remaining = scrub_info["remaining"] + presentation_byte_base = 0 + if range_header: + effective_range = range_header + relative_presentation_range = False + elif range_header and prior_presentation_base: + # Post-scrub client Ranges are relative to the shorter presented file. + effective_range = _map_client_range_through_presentation( + range_header, prior_presentation_base, + ) + relative_presentation_range = True + presentation_byte_base = prior_presentation_base + presentation_remaining = prior_presentation_length + if debug: + logger.debug( + "Timeshift presentation range map: session=%s %s -> %s " + "(base=%d)", + session_id, range_header, effective_range, + prior_presentation_base, + ) + elif media_changed: + # Outside the opened archive: mint a fresh portal/CDN URL. Retargeting + # an old CDN token onto a new start path often still serves the prior + # programme (token is bound to the archive that minted it). + keep_archive = False + final_url = None + if debug: + logger.debug( + "Timeshift session re-anchor: session=%s %s -> %s " + "(outside open archive window, portal)", + session_id, prior_media_id, media_id, + ) + else: + keep_archive = True + final_url = raw_final_url or None + if range_header and prior_presentation_base: + effective_range = _map_client_range_through_presentation( + range_header, prior_presentation_base, + ) + relative_presentation_range = True + presentation_byte_base = prior_presentation_base + presentation_remaining = prior_presentation_length + if debug: + logger.debug( + "Timeshift presentation range map: session=%s %s -> %s " + "(base=%d)", + session_id, range_header, effective_range, + prior_presentation_base, + ) + _update_pool_position( redis_client, session_id, media_id=media_id, provider_timestamp=provider_timestamp, + keep_archive=keep_archive, ) release_cb = _make_release_once(redis_client, session_id, profile.id) @@ -2104,6 +2669,7 @@ def _stream_reused_session( stats_stream_id = int(raw_stream_id) except (TypeError, ValueError): stats_stream_id = None + try: response = _attempt_timeshift_stream( m3u_account=m3u_account, @@ -2118,7 +2684,7 @@ def _stream_reused_session( client_id=client_id, client_ip=client_ip, client_user_agent=client_user_agent, - range_header=range_header, + range_header=effective_range, channel_logo_id=channel_logo_id, user=user, redis_client=redis_client, @@ -2126,6 +2692,11 @@ def _stream_reused_session( release_cb=release_cb, pool_session_id=session_id, stats_stream_id=stats_stream_id, + final_url=final_url, + rewrite_plain_get=rewrite_plain_get, + presentation_remaining=presentation_remaining, + presentation_byte_base=presentation_byte_base, + relative_presentation_range=relative_presentation_range, ) except Exception: _discard_pool_session(redis_client, session_id, profile.id) @@ -2268,6 +2839,10 @@ def _register_stats_client( pipe.hset(client_key, mapping=client_payload) if playback_base_secs is None: pipe.hdel(client_key, "playback_base_secs") + # Fresh proxy reanchor clears client-reported pause; EOF probes keep + # the prior anchor and therefore leave ``paused`` alone. + if str(position_anchor_at) == str(now): + pipe.hdel(client_key, "paused") pipe.expire(client_key, CLIENT_TTL_SECONDS) pipe.sadd(client_set_key, client_id) pipe.expire(client_set_key, CLIENT_TTL_SECONDS) @@ -2328,8 +2903,13 @@ def _unregister_stats_client(redis_client, stats_channel_id, client_id): logger.warning("Timeshift stats unregister failed: %s", exc) -def _open_upstream(url, user_agent, range_header): - """Open upstream HTTP; redirects are followed (XC load-balancer nodes).""" +def _open_upstream(url, user_agent, range_header, *, allow_redirects=True): + """Open upstream HTTP. + + Portal URLs need ``allow_redirects=True`` (XC → CDN). Cached CDN + ``final_url`` values use ``allow_redirects=False`` so reconnects do not + mint a new provider timeshift lock/token. + """ # identity: raw peek bytes are not gzip-transparent. headers = {"Accept-Encoding": "identity"} if user_agent: @@ -2344,6 +2924,7 @@ def _open_upstream(url, user_agent, range_header): ConfigHelper.connection_timeout(), ConfigHelper.chunk_timeout(), ), + allow_redirects=allow_redirects, ) @@ -2402,9 +2983,24 @@ def _stream_from_provider( stats_stream_id=None, stream_stats=None, duration_minutes=None, + final_url=None, + rewrite_plain_get=False, + presentation_remaining=None, + presentation_byte_base=None, + relative_presentation_range=False, ): """Try each upstream URL until one returns streamable MPEG-TS. + When ``final_url`` is set (cached post-redirect CDN URL from a prior + request in this session), try it first with redirects disabled (same + pattern as VOD) so Range reconnects do not mint a new portal token. + + ``rewrite_plain_get`` maps an injected CDN Range (XC start-URL scrub) back + to a plain ``200`` + remaining ``Content-Length`` so clients that rebuild + the XC start URL (instead of sending ``Range``) still get provider-like + headers. Subsequent client Ranges are relative to that window and must be + remapped via ``relative_presentation_range``. + Sets ``timeshift_decisive`` on auth/ban-class failures (401/403/406) so the failover loop skips the rest of that account's streams. ``release_cb`` frees the provider slot when the streaming response is closed. @@ -2427,16 +3023,33 @@ def _stream_from_provider( ordered_urls = list(candidate_urls) original_indices = list(range(len(candidate_urls))) + # (url, allow_redirects, cached_final, format_index_or_None) + attempts = [] + if final_url: + attempts.append((final_url, False, True, None)) + for url, orig_idx in zip(ordered_urls, original_indices): + attempts.append((url, True, False, orig_idx)) + # Peek for MPEG-TS sync; some providers return HTTP 200 with PHP/HTML errors. upstream = None last_status = None - last_url = ordered_urls[0] + last_url = attempts[0][0] if attempts else "" winning_index = None + used_cached_final = False decisive_failure = False - for url, orig_idx in zip(ordered_urls, original_indices): + for url, follow_redirects, cached_final, orig_idx in attempts: try: - response = _open_upstream(url, user_agent, range_header) + response = _open_upstream( + url, user_agent, range_header, allow_redirects=follow_redirects, + ) except requests.exceptions.RequestException as exc: + if cached_final: + logger.warning( + "Timeshift cached CDN unreachable (%s): %s; falling back to portal", + _redact_url(url), type(exc).__name__, + ) + _clear_pool_final_url(redis_client, pool_session_id) + continue logger.error( "Timeshift provider unreachable (%s): %s", _redact_url(url), type(exc).__name__, @@ -2445,11 +3058,12 @@ def _stream_from_provider( HttpResponseBadRequest("Provider connection error") ) last_status = response.status_code - last_url = url + last_url = getattr(response, "url", None) or url + cascade_label = "cdn" if cached_final else (orig_idx if orig_idx is not None else "?") if debug: logger.debug( - "Timeshift cascade[%d]: status=%d type=%s url=%s", - orig_idx, response.status_code, + "Timeshift cascade[%s]: status=%d type=%s url=%s", + cascade_label, response.status_code, response.headers.get("Content-Type", "?"), _redact_url(url), ) @@ -2470,12 +3084,14 @@ def _stream_from_provider( response._peek_data = peek upstream = response winning_index = orig_idx + used_cached_final = cached_final break sync_offset = find_ts_sync(peek) if peek else -1 if sync_offset >= 0: response._peek_data = peek[sync_offset:] upstream = response winning_index = orig_idx + used_cached_final = cached_final break snippet = peek[:200].decode("utf-8", errors="replace") if peek else "(empty)" logger.warning( @@ -2487,9 +3103,19 @@ def _stream_from_provider( _redact_url(url), ) response.close() + if cached_final: + _clear_pool_final_url(redis_client, pool_session_id) last_status = 404 # Treat as soft rejection for cascade continue response.close() + if cached_final: + # Expired/rotated CDN token; clear and retry portal shapes. + logger.info( + "Timeshift cached CDN returned %d, clearing final_url for session %s", + response.status_code, pool_session_id, + ) + _clear_pool_final_url(redis_client, pool_session_id) + continue # Auth/ban-class statuses stop trying more shapes on this account; 5xx does not. code = response.status_code if code in (401, 403, 406) or 300 <= code < 400: @@ -2518,6 +3144,20 @@ def _stream_from_provider( _store_pool_content_length(redis_client, pool_session_id, upstream) _store_pool_serving_range(redis_client, pool_session_id, range_header) + # Capture post-redirect CDN URL for reconnects (VOD final_url pattern). + resolved_url = getattr(upstream, "url", None) or last_url + if resolved_url: + _store_pool_final_url(redis_client, pool_session_id, resolved_url) + # Portal opens define (or redefine) the session archive window; CDN scrubs reuse it. + # force=False: after keep_archive=False clear, these keys are empty and get set; + # after CDN→portal fallback mid-session, keep the original anchor. + _ensure_pool_archive_anchor( + redis_client, + pool_session_id, + timestamp=timestamp_utc, + duration_minutes=duration_minutes, + force=False, + ) representation_length = _extract_representation_length(upstream) if representation_length is None and redis_client and pool_session_id: @@ -2532,19 +3172,64 @@ def _stream_from_provider( except (TypeError, ValueError): representation_length = None - client_length_headers = _build_downstream_length_headers( - range_header=range_header, - status_code=status, - representation_length=representation_length, - upstream_content_range=content_range or None, - upstream_content_length=upstream.headers.get("Content-Length"), - streaming=True, - ) + if rewrite_plain_get and status == 206: + # Client sent a plain GET with a new start; we injected CDN Range. + # Present a provider-like 200 with remaining Content-Length. + remaining = presentation_remaining + if remaining is None and representation_length is not None: + start = _parse_range_start(range_header) or 0 + remaining = max(int(representation_length) - int(start), 0) + status = 200 + content_range = "" + client_length_headers = {"Accept-Ranges": "bytes"} + if remaining is not None: + client_length_headers["Content-Length"] = str(remaining) + byte_base = presentation_byte_base + if byte_base is None: + byte_base = _parse_range_start(range_header) or 0 + _store_pool_presentation_window( + redis_client, pool_session_id, remaining, byte_base=byte_base, + ) + else: + outbound_content_range = content_range or None + if relative_presentation_range and outbound_content_range: + outbound_content_range = _presentation_relative_content_range( + outbound_content_range, + presentation_byte_base=presentation_byte_base, + presentation_length=presentation_remaining, + ) + client_length_headers = _build_downstream_length_headers( + # Absolute CDN Range must not leak into Content-Range synthesis; + # the client still thinks this file starts at presentation byte 0. + range_header=None if relative_presentation_range else range_header, + status_code=status, + representation_length=( + presentation_remaining + if relative_presentation_range and presentation_remaining is not None + else representation_length + ), + upstream_content_range=outbound_content_range, + upstream_content_length=upstream.headers.get("Content-Length"), + streaming=True, + ) + if presentation_remaining is not None and presentation_byte_base is not None: + _store_pool_presentation_window( + redis_client, pool_session_id, presentation_remaining, + byte_base=presentation_byte_base, + ) + elif representation_length is not None and not range_header: + _store_pool_presentation_window( + redis_client, pool_session_id, representation_length, byte_base=0, + ) programme_duration_secs = None if duration_minutes: programme_duration_secs = float(duration_minutes) * 60.0 + # XC start-URL scrub injects a CDN Range for the provider only. Stats must + # use the URL timestamp vs EPG; mapping archive bytes onto programme + # duration falsely parks the card at an unrelated offset. + stats_range_start = None if rewrite_plain_get else _parse_range_start(range_header) _register_stats_client( redis_client, stats_channel_id, @@ -2562,7 +3247,7 @@ def _stream_from_provider( channel_uuid=channel_uuid, stats_stream_id=stats_stream_id, stream_stats=stream_stats, - range_start=_parse_range_start(range_header), + range_start=stats_range_start, representation_length=representation_length, programme_duration_secs=programme_duration_secs, emit_stats_update=True, diff --git a/core/models.py b/core/models.py index 676ef02d..ad9677c6 100644 --- a/core/models.py +++ b/core/models.py @@ -280,18 +280,8 @@ class CoreSettings(models.Model): "epg_match_ignore_prefixes": [], "epg_match_ignore_suffixes": [], "epg_match_ignore_custom": [], - # XC catch-up: forced XMLTV lookback (0 = auto-detect, capped at 30). - "xmltv_prev_days_override": 0, }) - @classmethod - def get_xmltv_prev_days_override(cls): - """Global XC XMLTV prev_days default (0 = auto-detect from provider archives).""" - try: - return int(cls.get_epg_settings().get("xmltv_prev_days_override", 0) or 0) - except (TypeError, ValueError): - return 0 - @classmethod def _safe_string_list(cls, value): """Return a list of strings, filtering out non-list or non-string values.""" diff --git a/core/utils.py b/core/utils.py index 84bf46fe..4ffa90fa 100644 --- a/core/utils.py +++ b/core/utils.py @@ -710,7 +710,7 @@ def validate_flexible_url(value): # Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234 # Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value # Also supports authentication: rtsp://user:pass@hostname/path - non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' + non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9_\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' non_fqdn_match = re.match(non_fqdn_pattern, value) if non_fqdn_match: diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index cdedb0c4..d6395c29 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -7,7 +7,7 @@ from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.live_proxy.views import stream_xc from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode -from apps.timeshift.views import timeshift_proxy +from apps.timeshift.views import timeshift_proxy, timeshift_proxy_query urlpatterns = [ # API Routes @@ -43,10 +43,15 @@ urlpatterns = [ name="xc_stream_endpoint", ), path( - "timeshift/////", + "timeshift/////", timeshift_proxy, name="timeshift_proxy", ), + path( + "streaming/timeshift.php", + timeshift_proxy_query, + name="timeshift_proxy_query", + ), # XC VOD endpoints path( "movie///.", diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini index 6753d1a6..80f45ae8 100644 --- a/docker/uwsgi.debug.ini +++ b/docker/uwsgi.debug.ini @@ -66,7 +66,9 @@ ignore-write-errors = true disable-write-exception = true # Debugging settings -py-autoreload = 1 +# Reload API workers: touch /app/.uwsgi-reload +; py-autoreload = 1 +touch-workers-reload = /app/.uwsgi-reload honour-stdin = true # Environment variables diff --git a/frontend/src/components/ProgramPreview.jsx b/frontend/src/components/ProgramPreview.jsx index 5163c0a5..0471ed53 100644 --- a/frontend/src/components/ProgramPreview.jsx +++ b/frontend/src/components/ProgramPreview.jsx @@ -84,6 +84,8 @@ const ProgramPreview = ({ label = 'Now Playing:', timelineMode = 'live', playbackElapsedSeconds = 0, + accentColor = 'green.5', + accentIconColor = '#22c55e', }) => { const [isExpanded, setIsExpanded] = useState(false); @@ -119,8 +121,8 @@ const ProgramPreview = ({ return ( <> - - + + {label} diff --git a/frontend/src/components/cards/TimeshiftConnectionCard.jsx b/frontend/src/components/cards/TimeshiftConnectionCard.jsx index 88736af4..fedec0ed 100644 --- a/frontend/src/components/cards/TimeshiftConnectionCard.jsx +++ b/frontend/src/components/cards/TimeshiftConnectionCard.jsx @@ -164,7 +164,7 @@ const TimeshiftConnectionCard = ({ null; // Play position from URL timestamp + EPG window + stream-open anchor. - const playbackBaseRef = useRef({ base: null, receivedAtMs: 0 }); + const playbackBaseRef = useRef({ base: null, receivedAtMs: 0, paused: false }); useEffect(() => { const computed = computeCatchupPlaybackSeconds({ programmeStart: timeshiftSession.programme_start, @@ -172,27 +172,35 @@ const TimeshiftConnectionCard = ({ programDurationSecs: programmePreview?.duration_secs, positionAnchorAt: timeshiftSession.position_anchor_at, playbackBaseSecs: timeshiftSession.playback_base_secs, + paused: Boolean(timeshiftSession.paused), nowMs: Date.now(), }); if (computed != null) { playbackBaseRef.current = { base: computed, receivedAtMs: Date.now(), + paused: Boolean(timeshiftSession.paused), }; } }, [ timeshiftSession.programme_start, timeshiftSession.position_anchor_at, timeshiftSession.playback_base_secs, + timeshiftSession.paused, programmePreview?.start_time, programmePreview?.duration_secs, ]); - const { base: playbackBase, receivedAtMs: playbackReceivedAtMs } = - playbackBaseRef.current; + const { + base: playbackBase, + receivedAtMs: playbackReceivedAtMs, + paused: playbackPaused, + } = playbackBaseRef.current; const playbackElapsedSeconds = playbackBase != null - ? playbackBase + (Date.now() - playbackReceivedAtMs) / 1000 + ? playbackPaused + ? playbackBase + : playbackBase + (Date.now() - playbackReceivedAtMs) / 1000 : getConnectionDurationSeconds(connection); const getConnectionStartTime = useCallback( @@ -291,7 +299,9 @@ const TimeshiftConnectionCard = ({ diff --git a/frontend/src/components/forms/OutputProfile.jsx b/frontend/src/components/forms/OutputProfile.jsx index 541c911b..610c0ef4 100644 --- a/frontend/src/components/forms/OutputProfile.jsx +++ b/frontend/src/components/forms/OutputProfile.jsx @@ -1,8 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Modal, TextInput, @@ -13,27 +10,14 @@ import { Stack, Checkbox, } from '@mantine/core'; - -const BUILT_IN_COMMANDS = [ - { value: 'ffmpeg', label: 'FFmpeg' }, - { value: '__custom__', label: 'Custom…' }, -]; - -const COMMAND_EXAMPLES = { - ffmpeg: - '-i pipe:0 -c:v libx264 -b:v 2000k -vf scale=-2:720 -c:a copy -f mpegts pipe:1', -}; - -const toCommandSelection = (command) => - BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__') - ? command - : '__custom__'; - -const schema = Yup.object({ - name: Yup.string().required('Name is required'), - command: Yup.string().required('Command is required'), - parameters: Yup.string(), -}); +import { + addOutputProfile, + BUILT_IN_COMMANDS, + COMMAND_EXAMPLES, + getResolver, + toCommandSelection, + updateOutputProfile, +} from '../../utils/forms/OutputProfileUtils'; const OutputProfile = ({ profile = null, isOpen, onClose }) => { const [commandSelection, setCommandSelection] = useState('ffmpeg'); @@ -57,7 +41,7 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { watch, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); useEffect(() => { @@ -67,9 +51,9 @@ const OutputProfile = ({ profile = null, isOpen, onClose }) => { const onSubmit = async (values) => { if (profile?.id) { - await API.updateOutputProfile({ id: profile.id, ...values }); + await updateOutputProfile({ id: profile.id, ...values }); } else { - await API.addOutputProfile(values); + await addOutputProfile(values); } reset(); onClose(); diff --git a/frontend/src/components/forms/ServerGroup.jsx b/frontend/src/components/forms/ServerGroup.jsx index 80c8c1a4..0d5ecd05 100644 --- a/frontend/src/components/forms/ServerGroup.jsx +++ b/frontend/src/components/forms/ServerGroup.jsx @@ -1,20 +1,13 @@ import React, { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Button, Flex, Modal, TextInput } from '@mantine/core'; +import { + getResolver, + updateServerGroup, + addServerGroup, +} from '../../utils/forms/ServerGroupUtils'; -const schema = Yup.object({ - name: Yup.string().required('Name is required'), -}); - -const ServerGroupForm = ({ - serverGroup = null, - isOpen, - onClose, - onSaved, -}) => { +const ServerGroupForm = ({ serverGroup = null, isOpen, onClose, onSaved }) => { const defaultValues = useMemo( () => ({ name: serverGroup?.name || '', @@ -29,16 +22,13 @@ const ServerGroupForm = ({ reset, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { - let response; - if (serverGroup?.id) { - response = await API.updateServerGroup({ id: serverGroup.id, ...values }); - } else { - response = await API.addServerGroup(values); - } + const response = serverGroup?.id + ? await updateServerGroup({ id: serverGroup.id, ...values }) + : await addServerGroup(values); if (response) { onSaved?.(response); diff --git a/frontend/src/components/forms/__tests__/OutputProfile.test.jsx b/frontend/src/components/forms/__tests__/OutputProfile.test.jsx new file mode 100644 index 00000000..f06e89e1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/OutputProfile.test.jsx @@ -0,0 +1,534 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Module-level form state ──────────────────────────────────────────────────── +const __form = { values: {}, resetSpy: null }; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/OutputProfileUtils', () => ({ + BUILT_IN_COMMANDS: [ + { value: 'ffmpeg', label: 'FFmpeg' }, + { value: '__custom__', label: 'Custom…' }, + ], + COMMAND_EXAMPLES: { + ffmpeg: '-i pipe:0 -c:v libx264 -f mpegts pipe:1', + }, + addOutputProfile: vi.fn(), + updateOutputProfile: vi.fn(), + getResolver: vi.fn(() => undefined), + toCommandSelection: vi.fn((cmd) => + cmd === 'ffmpeg' ? 'ffmpeg' : '__custom__' + ), +})); + +// ── react-hook-form ──────────────────────────────────────────────────────────── +vi.mock('react-hook-form', async () => { + const React = await import('react'); + return { + useForm: vi.fn(({ defaultValues } = {}) => { + const [formValues, setFormValues] = React.useState(() => { + const vals = defaultValues || {}; + Object.assign(__form.values, vals); + return vals; + }); + + const updateField = (name, value) => { + __form.values[name] = value; + setFormValues((prev) => ({ ...prev, [name]: value })); + }; + + const register = (name) => ({ + name, + value: __form.values[name] ?? '', + onChange: (e) => updateField(name, e.target.value), + onBlur: () => {}, + }); + + const setValue = (name, value) => updateField(name, value); + const watch = (name) => formValues[name]; + + const handleSubmit = (onSubmit) => (e) => { + e?.preventDefault?.(); + return onSubmit({ ...__form.values }); + }; + + const resetImpl = React.useCallback((newValues) => { + const vals = newValues || defaultValues || {}; + Object.assign(__form.values, vals); + setFormValues({ ...vals }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const resetRef = React.useRef(null); + if (!resetRef.current) { + resetRef.current = vi.fn((...args) => resetImpl(...args)); + __form.resetSpy = resetRef.current; + } + + return { + register, + handleSubmit, + formState: { errors: {}, isSubmitting: false }, + reset: resetRef.current, + setValue, + watch, + }; + }), + }; +}); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Button: ({ children, type, disabled }) => ( + + ), + Checkbox: ({ label, checked, onChange }) => ( +
+ + + onChange({ currentTarget: { checked: e.target.checked } }) + } + /> +
+ ), + Flex: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Select: ({ label, value, onChange, data, disabled }) => ( +
+ + +
+ ), + Stack: ({ children }) =>
{children}
, + Textarea: ({ + label, + name, + value, + onChange, + placeholder, + description, + disabled, + ...rest + }) => ( +
+ + {description && ( +
+ {description} +
+ )} +