mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
221 lines
6.3 KiB
TypeScript
221 lines
6.3 KiB
TypeScript
/**
|
|
* TimeSync — NTP-style time synchronisation with the Jellyfin server.
|
|
*
|
|
* Merged port of jellyfin-web's `core/timeSync/{TimeSync,TimeSyncServer,
|
|
* TimeSyncCore}.js` — three classes that exist on web because the
|
|
* abstract layer supports syncing against other group members, not just
|
|
* the server. RN only syncs against the server, so it's one class.
|
|
*
|
|
* Algorithm: repeatedly time a round-trip request to `getUtcTime`,
|
|
* compute `offset = ((requestReceived - requestSent) + (responseSent -
|
|
* responseReceived)) / 2`, keep the minimum-delay measurement out of
|
|
* the last 8. This is the standard NTP outlier-rejection trick — the
|
|
* measurement with the shortest delay is the most accurate because
|
|
* less network jitter could have skewed the timestamps.
|
|
*
|
|
* Polling: greedy mode at 1s intervals for the first 3 pings to warm
|
|
* up the offset, then low-profile at 60s intervals for steady-state.
|
|
* `forceUpdate()` resets to greedy mode (called on group join).
|
|
*/
|
|
|
|
import type { Api } from "@jellyfin/sdk";
|
|
import { getTimeSyncApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { EventEmitter } from "../EventEmitter";
|
|
|
|
const NumberOfTrackedMeasurements = 8;
|
|
const PollingIntervalGreedy = 1000; // ms
|
|
const PollingIntervalLowProfile = 60000; // ms
|
|
const GreedyPingCount = 3;
|
|
|
|
class Measurement {
|
|
requestSent: number;
|
|
requestReceived: number;
|
|
responseSent: number;
|
|
responseReceived: number;
|
|
|
|
constructor(
|
|
requestSent: Date,
|
|
requestReceived: Date,
|
|
responseSent: Date,
|
|
responseReceived: Date,
|
|
) {
|
|
this.requestSent = requestSent.getTime();
|
|
this.requestReceived = requestReceived.getTime();
|
|
this.responseSent = responseSent.getTime();
|
|
this.responseReceived = responseReceived.getTime();
|
|
}
|
|
|
|
/** Time offset (ms): positive means server clock is ahead of ours. */
|
|
getOffset(): number {
|
|
return (
|
|
(this.requestReceived -
|
|
this.requestSent +
|
|
(this.responseSent - this.responseReceived)) /
|
|
2
|
|
);
|
|
}
|
|
|
|
/** Round-trip delay (ms), excluding server processing. */
|
|
getDelay(): number {
|
|
return (
|
|
this.responseReceived -
|
|
this.requestSent -
|
|
(this.responseSent - this.requestReceived)
|
|
);
|
|
}
|
|
|
|
/** One-way ping (ms). */
|
|
getPing(): number {
|
|
return this.getDelay() / 2;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tracks the offset between this client's clock and the Jellyfin server's
|
|
* clock, and exposes conversions between local and remote Dates.
|
|
*
|
|
* Listeners:
|
|
* - `"update"` (timeOffset: number, ping: number) — fires on every
|
|
* successful ping. Errors are logged but not emitted; consumers
|
|
* should treat absence of updates as transient.
|
|
*/
|
|
export class TimeSync extends EventEmitter {
|
|
private api: Api;
|
|
private pingStop = true;
|
|
private pollingInterval = PollingIntervalGreedy;
|
|
private poller: ReturnType<typeof setTimeout> | null = null;
|
|
private pings = 0;
|
|
private measurement: Measurement | null = null;
|
|
private measurements: Measurement[] = [];
|
|
|
|
constructor(api: Api) {
|
|
super();
|
|
this.api = api;
|
|
}
|
|
|
|
/** Called when the user switches Jellyfin servers. */
|
|
updateApiClient(api: Api): void {
|
|
this.api = api;
|
|
}
|
|
|
|
/** Whether we've completed at least one successful measurement. */
|
|
isReady(): boolean {
|
|
return !!this.measurement;
|
|
}
|
|
|
|
/** Current best-estimate time offset (ms). */
|
|
getTimeOffset(): number {
|
|
return this.measurement ? this.measurement.getOffset() : 0;
|
|
}
|
|
|
|
/** Current best-estimate one-way ping (ms). */
|
|
getPing(): number {
|
|
return this.measurement ? this.measurement.getPing() : 0;
|
|
}
|
|
|
|
/** Convert a server-time Date to local time. */
|
|
remoteDateToLocal(remote: Date): Date {
|
|
return new Date(remote.getTime() - this.getTimeOffset());
|
|
}
|
|
|
|
/** Convert a local Date to server time. */
|
|
localDateToRemote(local: Date): Date {
|
|
return new Date(local.getTime() + this.getTimeOffset());
|
|
}
|
|
|
|
/** Start polling. Idempotent. */
|
|
startPing(): void {
|
|
this.pingStop = false;
|
|
this.scheduleNextPing();
|
|
}
|
|
|
|
/** Stop polling. Idempotent. */
|
|
stopPing(): void {
|
|
this.pingStop = true;
|
|
if (this.poller) {
|
|
clearTimeout(this.poller);
|
|
this.poller = null;
|
|
}
|
|
}
|
|
|
|
/** Reset to greedy polling and force a fresh measurement immediately. */
|
|
forceUpdate(): void {
|
|
this.stopPing();
|
|
this.pollingInterval = PollingIntervalGreedy;
|
|
this.pings = 0;
|
|
this.startPing();
|
|
}
|
|
|
|
/** Drop all measurements. Used on group leave. */
|
|
resetMeasurements(): void {
|
|
this.measurement = null;
|
|
this.measurements = [];
|
|
}
|
|
|
|
/** Full teardown on provider unmount. */
|
|
destroy(): void {
|
|
this.stopPing();
|
|
this.resetMeasurements();
|
|
this.removeAllListeners();
|
|
}
|
|
|
|
private scheduleNextPing(): void {
|
|
if (this.poller || this.pingStop) return;
|
|
this.poller = setTimeout(() => {
|
|
this.poller = null;
|
|
this.requestPing()
|
|
.then((result) => this.onPingResponse(result))
|
|
.catch((error) => {
|
|
console.error("SyncPlay TimeSync: ping failed", error);
|
|
})
|
|
.finally(() => this.scheduleNextPing());
|
|
}, this.pollingInterval);
|
|
}
|
|
|
|
private async requestPing() {
|
|
const requestSent = new Date();
|
|
const response = await getTimeSyncApi(this.api).getUtcTime();
|
|
const responseReceived = new Date();
|
|
const data = response.data;
|
|
const requestReceived = new Date(data.RequestReceptionTime as string);
|
|
const responseSent = new Date(data.ResponseTransmissionTime as string);
|
|
return { requestSent, requestReceived, responseSent, responseReceived };
|
|
}
|
|
|
|
private onPingResponse(result: {
|
|
requestSent: Date;
|
|
requestReceived: Date;
|
|
responseSent: Date;
|
|
responseReceived: Date;
|
|
}): void {
|
|
const measurement = new Measurement(
|
|
result.requestSent,
|
|
result.requestReceived,
|
|
result.responseSent,
|
|
result.responseReceived,
|
|
);
|
|
|
|
this.measurements.push(measurement);
|
|
if (this.measurements.length > NumberOfTrackedMeasurements) {
|
|
this.measurements.shift();
|
|
}
|
|
|
|
// Outlier rejection: pick the measurement with the shortest delay.
|
|
const sorted = [...this.measurements].sort(
|
|
(a, b) => a.getDelay() - b.getDelay(),
|
|
);
|
|
this.measurement = sorted[0];
|
|
|
|
// Throttle once we've warmed up.
|
|
if (this.pings >= GreedyPingCount) {
|
|
this.pollingInterval = PollingIntervalLowProfile;
|
|
} else {
|
|
this.pings++;
|
|
}
|
|
|
|
this.emit("update", this.getTimeOffset(), this.getPing());
|
|
}
|
|
}
|
|
|
|
export default TimeSync;
|