import i18n from "../i18n"; export function relativeTime(iso: string | null): string { if (!iso) return ""; return relativeFromMs(new Date(iso).getTime()); } /** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */ export function formatBytes(bytes: number | null | undefined): string { const n = Number(bytes); if (!n || n < 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; let v = n; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; } /** Download speed from bytes/sec, e.g. "2.4 MB/s". */ export function formatSpeed(bps: number | null | undefined): string { if (!bps) return ""; return `${formatBytes(bps)}/s`; } /** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces, * and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no * longer be paired with the wrong label, which is exactly what shifted every tier by one from * ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up the scale). * * MUST stay sorted ascending, with the first row doubling as the "just now" threshold: sorting it * largest-first — the natural instinct — would make every timestamp under a year read "just now". * Module-level because it is constant, and exported so the tests can hold that invariant. */ // A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under // noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing. export const RELATIVE_UNITS: readonly [ [number, Intl.RelativeTimeFormatUnit], ...[number, Intl.RelativeTimeFormatUnit][], ] = [ [60, "minute"], [3600, "hour"], [86400, "day"], [604800, "week"], [2592000, "month"], [31536000, "year"], ]; // One formatter per language, built lazily: relativeFromMs runs once per rendered timestamp // across a virtualized feed, and constructing an Intl formatter is the expensive part. Keyed by // language because the user can switch it at runtime. `narrow` is the style that matches the // app's compact timestamps; `numeric: "always"` keeps it counting ("1d ago", never "yesterday"). const relativeFormatters = new Map(); function relativeFormatter(lang: string): Intl.RelativeTimeFormat { let formatter = relativeFormatters.get(lang); if (!formatter) { formatter = new Intl.RelativeTimeFormat(lang, { style: "narrow", numeric: "always" }); relativeFormatters.set(lang, formatter); } return formatter; } /** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for * client-side notifications whose timestamps are already numbers). One implementation shared * instead of re-implemented per component. Empty string for a timestamp that can't be read, so a * caller's `|| "—"` fallback covers it like a missing one. */ export function relativeFromMs(ms: number): string { const secs = Math.max(0, (Date.now() - ms) / 1000); if (!Number.isFinite(secs)) return ""; // Sub-minute is the one case Intl has no wording for — it would say "0m ago". if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow"); let [div, unit] = RELATIVE_UNITS[0]; for (const [unitSecs, unitName] of RELATIVE_UNITS) { if (secs < unitSecs) break; div = unitSecs; unit = unitName; } return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the // /channel/ form. One place so every channel link across the app stays consistent. export function channelYouTubeUrl(id: string, handle?: string | null): string { return handle ? `https://www.youtube.com/@${handle.replace(/^@/, "")}` : `https://www.youtube.com/channel/${id}`; } export function formatDuration(sec: number | null): string { if (sec == null) return ""; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = Math.floor(sec % 60); const pad = (n: number) => String(n).padStart(2, "0"); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } /** Compact human runtime ("1h 30m", "45m") from seconds — used for Plex movie/episode lengths. * Empty string for a missing/zero duration (nothing to show). */ export function formatRuntime(sec?: number | null): string { if (!sec) return ""; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); return h ? `${h}h ${m}m` : `${m}m`; } // Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved // from i18n (quotaActions.) so it's translated in all UI languages; falls back to the raw // key for any unmapped/legacy value. export function quotaActionLabel(action: string): string { return i18n.t(`quotaActions.${action}`, { defaultValue: action }); } /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */ export function formatEta(seconds: number): string { if (seconds <= 0) return i18n.t("time.eta.done"); const hours = seconds / 3600; if (hours < 1) return i18n.t("time.eta.lessThanHour"); if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) }); return i18n.t("time.eta.days", { count: Math.round(hours / 24) }); } /** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */ export function formatTotalHours(sec: number): string { if (!sec) return "—"; const h = Math.round(sec / 3600); return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; } export function formatViews(n: number | null): string { if (n == null) return ""; if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; return String(n); } // A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null). export function formatCountOrDash(n: number | null | undefined): string { return n != null ? formatViews(n) : "—"; } // Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null. export function formatDate(iso: string | null | undefined, lang: string): string { return iso ? new Date(iso).toLocaleDateString(lang, { year: "numeric", month: "short", day: "numeric" }) : ""; }