feat(timeshift): add position reporting for catch-up sessions

- Introduced a new endpoint `POST /api/catchup/sessions/<session_id>/position/` for native clients to report their playhead position and pause state during catch-up sessions.
- Updated the catch-up session handling to include a `paused` flag, allowing accurate tracking of playback state without seeking the provider stream.
- Enhanced the Redis storage mechanism to accommodate the new position and pause data, ensuring real-time updates for admin stats.
- Added tests to validate the new position reporting functionality and its integration with existing catch-up features.
This commit is contained in:
SergeantPanda 2026-07-16 22:06:27 +00:00
parent 14bfd25d9a
commit 6f62d807f4
11 changed files with 395 additions and 11 deletions

View file

@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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}/{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/<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. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **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 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).

View file

@ -10,6 +10,11 @@ urlpatterns = [
api_views.CatchupSessionCreateAPIView.as_view(),
name="catchup-session-create",
),
path(
"sessions/<str:session_id>/position/",
api_views.CatchupSessionPositionAPIView.as_view(),
name="catchup-session-position",
),
path(
"sessions/<str:session_id>/",
api_views.CatchupSessionDestroyAPIView.as_view(),

View file

@ -9,6 +9,7 @@ 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 MAX_DURATION_MINUTES, parse_catchup_timestamp
@ -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):
@ -73,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 "
@ -186,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)

View file

@ -13,6 +13,7 @@ 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__)
@ -222,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.
@ -233,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):
@ -288,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 = {
@ -369,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:
@ -388,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"),
@ -467,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"),

View file

@ -206,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):

View file

@ -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
@ -108,6 +109,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):
@ -264,12 +277,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 = []

View file

@ -2839,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)

View file

@ -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 (
<>
<Group gap={5} wrap="nowrap">
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
<Radio size="14" style={{ color: accentIconColor, flexShrink: 0 }} />
<Text size="xs" fw={500} c={accentColor} style={{ flexShrink: 0 }}>
{label}
</Text>
<Tooltip label={program.title}>

View file

@ -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 = ({
<ProgramPreview
program={programmePreview}
timelineMode="catchup"
label="Watching:"
label={timeshiftSession.paused ? 'Paused:' : 'Watching:'}
accentColor={timeshiftSession.paused ? 'yellow.5' : 'green.5'}
accentIconColor={timeshiftSession.paused ? '#eab308' : '#22c55e'}
playbackElapsedSeconds={playbackElapsedSeconds}
/>
</Box>

View file

@ -23,10 +23,11 @@ export const computeCatchupPlaybackSeconds = ({
programDurationSecs,
positionAnchorAt,
playbackBaseSecs,
paused = false,
nowMs = getNowMs(),
}) => {
let elapsedSinceAnchor = 0;
if (positionAnchorAt != null) {
if (!paused && positionAnchorAt != null) {
const anchor = Number(positionAnchorAt);
if (!Number.isNaN(anchor)) {
elapsedSinceAnchor = Math.max(0, nowMs / 1000 - anchor);

View file

@ -35,4 +35,17 @@ describe('computeCatchupPlaybackSeconds', () => {
});
expect(position).toBeCloseTo(1830, 5);
});
it('does not advance while paused', () => {
const position = computeCatchupPlaybackSeconds({
programmeStart: '2026-07-10:14-00',
programStartTime: '2026-07-10T14:00:00+00:00',
programDurationSecs: 3600,
positionAnchorAt: 1000,
playbackBaseSecs: 900,
paused: true,
nowMs: 1300 * 1000,
});
expect(position).toBeCloseTo(900, 5);
});
});