mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
feat(timeshift): enhance catch-up programme handling and UI updates
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
- Updated the catch-up stats UI to advance to the next EPG programme when playback continues past the original programme's end, improving user experience. - Enhanced the `get_programme_info` function to resolve the guide entry based on the playhead position, ensuring accurate display of the current programme. - Introduced new utility functions for computing catch-up playback position and checking if the playhead is outside the displayed programme window, optimizing session management. - Added tests to validate the new functionality and ensure correct behaviour of the catch-up features.
This commit is contained in:
parent
6f62d807f4
commit
8bcc7c9ebe
6 changed files with 511 additions and 45 deletions
|
|
@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **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/<channel_uuid>?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/<session_id>/position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions/<session_id>/` 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. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
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).
|
||||
|
|
@ -227,8 +230,15 @@ def resolve_catchup_duration(channel, timestamp_str, client_hint=None):
|
|||
return get_programme_duration(channel, timestamp_str)
|
||||
|
||||
|
||||
def get_programme_info(channel, timestamp_str):
|
||||
"""Return EPG metadata for the programme airing at *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:
|
||||
|
|
@ -243,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,
|
||||
|
|
@ -260,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 []
|
||||
|
|
@ -282,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,
|
||||
|
|
@ -306,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 (
|
||||
|
|
|
|||
|
|
@ -56,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"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ import {
|
|||
stopTimeshiftSession,
|
||||
stopVODClient,
|
||||
} from '../utils/pages/StatsUtils.js';
|
||||
import {
|
||||
computeCatchupArchivePositionSecs,
|
||||
computeCatchupPlaybackSeconds,
|
||||
isCatchupPlayheadOutsideProgram,
|
||||
} from '../utils/cards/TimeshiftConnectionCardUtils.js';
|
||||
const VodConnectionCard = React.lazy(
|
||||
() => import('../components/cards/VodConnectionCard.jsx')
|
||||
);
|
||||
|
|
@ -342,8 +347,9 @@ const StatsPage = () => {
|
|||
};
|
||||
}, [activeChannelIds]); // Only re-run when active channel set changes
|
||||
|
||||
// Programme metadata for catch-up cards: fetch when sessions or seek
|
||||
// position (programme_start) change, then reschedule at programme end.
|
||||
// Catch-up programme metadata: fetch when missing or when the playhead has
|
||||
// left the cached programme's window; otherwise keep that result and only
|
||||
// do cheap local checks until the next boundary.
|
||||
const timeshiftProgrammeKey = useMemo(() => {
|
||||
return timeshiftSessions
|
||||
.map((session) => `${session.session_id}:${session.programme_start || ''}`)
|
||||
|
|
@ -352,9 +358,31 @@ const StatsPage = () => {
|
|||
}, [timeshiftSessions]);
|
||||
|
||||
const timeshiftSessionsRef = useRef(timeshiftSessions);
|
||||
const catchupProgramsRef = useRef(catchupPrograms);
|
||||
const programmeCheckRef = useRef(() => {});
|
||||
const playbackFingerprintRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
timeshiftSessionsRef.current = timeshiftSessions;
|
||||
|
||||
const fingerprint = timeshiftSessions
|
||||
.map(
|
||||
(session) =>
|
||||
`${session.session_id}:${session.playback_base_secs ?? ''}:${session.position_anchor_at ?? ''}:${session.paused ? 1 : 0}`
|
||||
)
|
||||
.sort()
|
||||
.join(',');
|
||||
if (
|
||||
playbackFingerprintRef.current &&
|
||||
fingerprint !== playbackFingerprintRef.current
|
||||
) {
|
||||
programmeCheckRef.current();
|
||||
}
|
||||
playbackFingerprintRef.current = fingerprint;
|
||||
}, [timeshiftSessions]);
|
||||
useEffect(() => {
|
||||
catchupProgramsRef.current = catchupPrograms;
|
||||
}, [catchupPrograms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeshiftProgrammeKey) {
|
||||
|
|
@ -362,45 +390,151 @@ const StatsPage = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
let inFlight = false;
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
const sessions = timeshiftSessionsRef.current.map((session) => ({
|
||||
session_id: session.session_id,
|
||||
channel_uuid: session.channel_uuid,
|
||||
programme_start: session.programme_start,
|
||||
}));
|
||||
const programs = await getCatchupPrograms(sessions);
|
||||
setCatchupPrograms(programs);
|
||||
|
||||
if (programs && Object.keys(programs).length > 0) {
|
||||
const now = new Date();
|
||||
let nearestEndTime = null;
|
||||
|
||||
Object.values(programs).forEach((program) => {
|
||||
if (program && program.end_time) {
|
||||
const endTime = new Date(program.end_time);
|
||||
if (
|
||||
endTime > now &&
|
||||
(!nearestEndTime || endTime < nearestEndTime)
|
||||
) {
|
||||
nearestEndTime = endTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (nearestEndTime) {
|
||||
const timeUntilChange = nearestEndTime.getTime() - now.getTime();
|
||||
const fetchDelay = Math.max(timeUntilChange + 5000, 0);
|
||||
timer = setTimeout(fetchPrograms, fetchDelay);
|
||||
}
|
||||
const clearTimer = () => {
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const sessionNeedsProgrammeFetch = (session, program) => {
|
||||
if (!program?.start_time || program.duration_secs == null) {
|
||||
return true;
|
||||
}
|
||||
return isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: session.programme_start,
|
||||
programStartTime: program.start_time,
|
||||
programDurationSecs: program.duration_secs,
|
||||
positionAnchorAt: session.position_anchor_at,
|
||||
playbackBaseSecs: session.playback_base_secs,
|
||||
paused: Boolean(session.paused),
|
||||
});
|
||||
};
|
||||
|
||||
const msUntilNearestBoundary = () => {
|
||||
let nearestMs = null;
|
||||
timeshiftSessionsRef.current.forEach((session) => {
|
||||
const program = catchupProgramsRef.current?.[session.session_id];
|
||||
if (!program?.start_time || program.duration_secs == null) {
|
||||
return;
|
||||
}
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: session.programme_start,
|
||||
programStartTime: program.start_time,
|
||||
programDurationSecs: program.duration_secs,
|
||||
positionAnchorAt: session.position_anchor_at,
|
||||
playbackBaseSecs: session.playback_base_secs,
|
||||
paused: Boolean(session.paused),
|
||||
capToDuration: false,
|
||||
allowNegative: true,
|
||||
});
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
if (position < 0) {
|
||||
nearestMs = 0;
|
||||
return;
|
||||
}
|
||||
const remainingMs = Math.max(
|
||||
0,
|
||||
(Number(program.duration_secs) - position) * 1000
|
||||
);
|
||||
if (nearestMs == null || remainingMs < nearestMs) {
|
||||
nearestMs = remainingMs;
|
||||
}
|
||||
});
|
||||
return nearestMs;
|
||||
};
|
||||
|
||||
const scheduleCheck = () => {
|
||||
clearTimer();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = timeshiftSessionsRef.current;
|
||||
const programs = catchupProgramsRef.current || {};
|
||||
if (
|
||||
sessions.some((session) =>
|
||||
sessionNeedsProgrammeFetch(session, programs[session.session_id])
|
||||
)
|
||||
) {
|
||||
fetchPrograms();
|
||||
return;
|
||||
}
|
||||
|
||||
// Wake near the programme end; also at least every 2s so a seek/heartbeat
|
||||
// that jumps the playhead is noticed without hitting the API.
|
||||
const remainingMs = msUntilNearestBoundary();
|
||||
const delay =
|
||||
remainingMs == null
|
||||
? 2000
|
||||
: Math.min(Math.max(remainingMs + 250, 250), 2000);
|
||||
timer = setTimeout(scheduleCheck, delay);
|
||||
};
|
||||
|
||||
const fetchPrograms = async () => {
|
||||
if (cancelled || inFlight) {
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
clearTimer();
|
||||
try {
|
||||
const existingPrograms = catchupProgramsRef.current || {};
|
||||
const sessions = timeshiftSessionsRef.current.map((session) => {
|
||||
const existing = existingPrograms[session.session_id];
|
||||
// Archive playhead relative to the URL programme (uncapped). When the
|
||||
// card has already advanced and we only have URL math, omit position
|
||||
// and let the API fall back to Redis.
|
||||
const positionSecs = computeCatchupArchivePositionSecs({
|
||||
programmeStart: session.programme_start,
|
||||
programStartTime: existing?.start_time,
|
||||
positionAnchorAt: session.position_anchor_at,
|
||||
playbackBaseSecs: session.playback_base_secs,
|
||||
paused: Boolean(session.paused),
|
||||
});
|
||||
const payload = {
|
||||
session_id: session.session_id,
|
||||
channel_uuid: session.channel_uuid,
|
||||
programme_start: session.programme_start,
|
||||
};
|
||||
if (positionSecs != null) {
|
||||
payload.position_secs = positionSecs;
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
const programs = await getCatchupPrograms(sessions);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
catchupProgramsRef.current = programs;
|
||||
setCatchupPrograms(programs);
|
||||
|
||||
const stillOutside = timeshiftSessionsRef.current.some((session) =>
|
||||
sessionNeedsProgrammeFetch(session, programs[session.session_id])
|
||||
);
|
||||
// No next guide entry (or unknown playhead): back off instead of spinning.
|
||||
timer = setTimeout(
|
||||
stillOutside ? fetchPrograms : scheduleCheck,
|
||||
stillOutside ? 30_000 : 250
|
||||
);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
programmeCheckRef.current = scheduleCheck;
|
||||
|
||||
fetchPrograms();
|
||||
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
cancelled = true;
|
||||
clearTimer();
|
||||
programmeCheckRef.current = () => {};
|
||||
};
|
||||
}, [timeshiftProgrammeKey]);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export const computeCatchupPlaybackSeconds = ({
|
|||
playbackBaseSecs,
|
||||
paused = false,
|
||||
nowMs = getNowMs(),
|
||||
capToDuration = true,
|
||||
allowNegative = false,
|
||||
}) => {
|
||||
let elapsedSinceAnchor = 0;
|
||||
if (!paused && positionAnchorAt != null) {
|
||||
|
|
@ -34,12 +36,26 @@ export const computeCatchupPlaybackSeconds = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
|
||||
let position = Number(playbackBaseSecs) + elapsedSinceAnchor;
|
||||
if (position < 0) {
|
||||
// Byte-range / client-reported bases are relative to the programme that
|
||||
// contained the original catch-up URL. When the stats card has advanced to a
|
||||
// later guide entry, rebase onto that programme's start.
|
||||
let effectiveBase = playbackBaseSecs;
|
||||
if (effectiveBase != null && !Number.isNaN(Number(effectiveBase))) {
|
||||
const urlMs = parseCatchupTimestampMs(programmeStart);
|
||||
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
|
||||
if (
|
||||
urlMs != null &&
|
||||
epgStartMs != null &&
|
||||
!Number.isNaN(epgStartMs) &&
|
||||
epgStartMs > urlMs
|
||||
) {
|
||||
effectiveBase = Number(effectiveBase) - (epgStartMs - urlMs) / 1000;
|
||||
}
|
||||
let position = Number(effectiveBase) + elapsedSinceAnchor;
|
||||
if (!allowNegative && position < 0) {
|
||||
position = 0;
|
||||
}
|
||||
if (programDurationSecs != null) {
|
||||
if (capToDuration && programDurationSecs != null) {
|
||||
position = Math.min(position, Number(programDurationSecs));
|
||||
}
|
||||
return position;
|
||||
|
|
@ -52,15 +68,88 @@ export const computeCatchupPlaybackSeconds = ({
|
|||
}
|
||||
const urlOffsetSec = (urlMs - epgStartMs) / 1000;
|
||||
let position = urlOffsetSec + elapsedSinceAnchor;
|
||||
if (position < 0) {
|
||||
if (!allowNegative && position < 0) {
|
||||
position = 0;
|
||||
}
|
||||
if (programDurationSecs != null) {
|
||||
if (capToDuration && programDurationSecs != null) {
|
||||
position = Math.min(position, Number(programDurationSecs));
|
||||
}
|
||||
return position;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when the catch-up playhead is outside the displayed programme window.
|
||||
* Used to fetch the next guide entry once, then cache until that show ends.
|
||||
*/
|
||||
export const isCatchupPlayheadOutsideProgram = ({
|
||||
programmeStart,
|
||||
programStartTime,
|
||||
programDurationSecs,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused = false,
|
||||
nowMs = getNowMs(),
|
||||
}) => {
|
||||
if (programDurationSecs == null || !programStartTime) {
|
||||
return true;
|
||||
}
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart,
|
||||
programStartTime,
|
||||
programDurationSecs,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused,
|
||||
nowMs,
|
||||
capToDuration: false,
|
||||
allowNegative: true,
|
||||
});
|
||||
if (position == null) {
|
||||
return false;
|
||||
}
|
||||
return position < 0 || position >= Number(programDurationSecs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive playhead relative to the programme containing ``programmeStart``.
|
||||
* Uncapped so callers can detect when viewing has moved past that show.
|
||||
* Does not rebase onto a later displayed programme.
|
||||
*/
|
||||
export const computeCatchupArchivePositionSecs = ({
|
||||
programmeStart,
|
||||
programStartTime,
|
||||
positionAnchorAt,
|
||||
playbackBaseSecs,
|
||||
paused = false,
|
||||
nowMs = getNowMs(),
|
||||
}) => {
|
||||
let elapsedSinceAnchor = 0;
|
||||
if (!paused && positionAnchorAt != null) {
|
||||
const anchor = Number(positionAnchorAt);
|
||||
if (!Number.isNaN(anchor)) {
|
||||
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);
|
||||
}
|
||||
}
|
||||
|
||||
// playback_base is always relative to the URL programme, even after the
|
||||
// stats card has advanced to a later guide entry.
|
||||
if (playbackBaseSecs != null && !Number.isNaN(Number(playbackBaseSecs))) {
|
||||
return Math.max(0, Number(playbackBaseSecs) + elapsedSinceAnchor);
|
||||
}
|
||||
|
||||
const urlMs = parseCatchupTimestampMs(programmeStart);
|
||||
const epgStartMs = programStartTime ? Date.parse(programStartTime) : null;
|
||||
if (urlMs == null || epgStartMs == null || Number.isNaN(epgStartMs)) {
|
||||
return null;
|
||||
}
|
||||
// Only valid while programStartTime is the programme that contains the URL.
|
||||
// Once the card has advanced, omit position and let the API use Redis.
|
||||
if (epgStartMs > urlMs) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, (urlMs - epgStartMs) / 1000 + elapsedSinceAnchor);
|
||||
};
|
||||
|
||||
export const calculateConnectionDuration = (connection) => {
|
||||
const seconds = getConnectionDurationSeconds(connection);
|
||||
return toFriendlyDuration(seconds, 'seconds');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeCatchupPlaybackSeconds,
|
||||
computeCatchupArchivePositionSecs,
|
||||
isCatchupPlayheadOutsideProgram,
|
||||
parseCatchupTimestampMs,
|
||||
} from '../TimeshiftConnectionCardUtils.js';
|
||||
|
||||
|
|
@ -48,4 +50,108 @@ describe('computeCatchupPlaybackSeconds', () => {
|
|||
});
|
||||
expect(position).toBeCloseTo(900, 5);
|
||||
});
|
||||
|
||||
it('rebases byte-range playhead onto a later displayed programme', () => {
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1900,
|
||||
nowMs: 1000 * 1000,
|
||||
});
|
||||
// 1900s from 14:00 is 100s into the 14:30 programme.
|
||||
expect(position).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it('can return uncapped position past programme end', () => {
|
||||
const position = computeCatchupPlaybackSeconds({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1790,
|
||||
nowMs: 1040 * 1000,
|
||||
capToDuration: false,
|
||||
});
|
||||
expect(position).toBeCloseTo(1830, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCatchupPlayheadOutsideProgram', () => {
|
||||
it('is false while playhead is inside the programme', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 900,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once playhead reaches programme end', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1800,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects leaving a later displayed programme', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 3600,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('detects rewind before a later displayed programme', () => {
|
||||
expect(
|
||||
isCatchupPlayheadOutsideProgram({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
programDurationSecs: 1800,
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1200,
|
||||
nowMs: 1000 * 1000,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCatchupArchivePositionSecs', () => {
|
||||
it('returns uncapped absolute playhead from playback base', () => {
|
||||
const position = computeCatchupArchivePositionSecs({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:00:00+00:00',
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1900,
|
||||
nowMs: 1030 * 1000,
|
||||
});
|
||||
expect(position).toBeCloseTo(1930, 5);
|
||||
});
|
||||
|
||||
it('keeps URL-relative base after the card advances to a later programme', () => {
|
||||
const position = computeCatchupArchivePositionSecs({
|
||||
programmeStart: '2026-07-10:14-00',
|
||||
programStartTime: '2026-07-10T14:30:00+00:00',
|
||||
positionAnchorAt: 1000,
|
||||
playbackBaseSecs: 1900,
|
||||
nowMs: 1000 * 1000,
|
||||
});
|
||||
expect(position).toBeCloseTo(1900, 5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue