Add version update check feature for admin users

Checks current version against latest GitHub release every 6 hours via
cron job, caches result in appSettings, and surfaces updates through a
toast notification (once per browser session) and an animated badge in
the settings footer. Includes an admin toggle to enable/disable checks
(enabled by default). Also renames ServerSection to RegistrationSection
now that update checks are in their own card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 10:36:05 -05:00
co-authored by Claude Opus 4.6
parent cd63d4f92a
commit a5791b150f
12 changed files with 277 additions and 3 deletions
+5
View File
@@ -149,6 +149,11 @@ export async function toggleRegistration(open: boolean) {
setSetting("registrationOpen", String(open));
}
export async function toggleUpdateCheck(enabled: boolean) {
await getAdminSession();
setSetting("updateCheckEnabled", String(enabled));
}
// --- Backup actions ---
export async function createBackupAction(): Promise<BackupInfo> {
+7
View File
@@ -0,0 +1,7 @@
import { atomWithStorage, createJSONStorage } from "jotai/utils";
export const updateToastShownAtom = atomWithStorage<boolean>(
"sofa:update-toast-shown",
false,
createJSONStorage(() => sessionStorage),
);
+4
View File
@@ -27,6 +27,7 @@ import {
refreshTvChildren,
} from "@/lib/services/metadata";
import { getSetting } from "@/lib/services/settings";
import { performUpdateCheck } from "@/lib/services/update-check";
import { getTvDetails } from "@/lib/tmdb/client";
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
@@ -288,6 +289,9 @@ export function startJobs() {
schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob);
schedule("refreshTvChildren", "30 */12 * * *", refreshTvChildrenJob);
schedule("cacheImages", "0 1,13 * * *", cacheImagesJob);
schedule("updateCheck", "0 */6 * * *", async () => {
await performUpdateCheck();
});
log.info(`Started ${jobs.size} jobs`);
}
+1
View File
@@ -65,6 +65,7 @@ const JOB_NAMES = [
"refreshTvChildren",
"cacheImages",
"scheduledBackup",
"updateCheck",
];
function getDatabaseHealth(): SystemHealthData["database"] {
+103
View File
@@ -0,0 +1,103 @@
import { createLogger } from "@/lib/logger";
import { getSetting, setSetting } from "@/lib/services/settings";
import { APP_VERSION } from "@/lib/version";
const log = createLogger("update-check");
const GITHUB_RELEASES_URL =
"https://api.github.com/repos/jakejarvis/sofa/releases/latest";
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
export interface UpdateCheckResult {
updateAvailable: boolean;
currentVersion: string;
latestVersion: string | null;
releaseUrl: string | null;
lastCheckedAt: string | null;
}
export function isUpdateCheckEnabled(): boolean {
const setting = getSetting("updateCheckEnabled");
// Default to true when no setting exists
return setting !== "false";
}
/** Returns true if `latest` is strictly newer than `current` using semver comparison. */
function isNewerVersion(latest: string, current: string): boolean {
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
const [lMajor = 0, lMinor = 0, lPatch = 0] = parse(latest);
const [cMajor = 0, cMinor = 0, cPatch = 0] = parse(current);
if (lMajor !== cMajor) return lMajor > cMajor;
if (lMinor !== cMinor) return lMinor > cMinor;
return lPatch > cPatch;
}
export function getCachedUpdateCheck(): UpdateCheckResult {
const latestVersion = getSetting("updateCheckLatestVersion");
const releaseUrl = getSetting("updateCheckReleaseUrl");
const lastCheckedAt = getSetting("updateCheckLastCheckedAt");
return {
updateAvailable: latestVersion
? isNewerVersion(latestVersion, APP_VERSION)
: false,
currentVersion: APP_VERSION,
latestVersion,
releaseUrl,
lastCheckedAt,
};
}
export async function performUpdateCheck(): Promise<UpdateCheckResult> {
if (!isUpdateCheckEnabled()) {
return {
updateAvailable: false,
currentVersion: APP_VERSION,
latestVersion: null,
releaseUrl: null,
lastCheckedAt: null,
};
}
// Respect cache interval
const lastChecked = getSetting("updateCheckLastCheckedAt");
if (lastChecked) {
const elapsed = Date.now() - new Date(lastChecked).getTime();
if (elapsed < CHECK_INTERVAL_MS) {
return getCachedUpdateCheck();
}
}
try {
const res = await fetch(GITHUB_RELEASES_URL, {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "sofa-update-check",
},
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
const data = (await res.json()) as { tag_name: string; html_url: string };
const version = data.tag_name.replace(/^v/, "");
setSetting("updateCheckLatestVersion", version);
setSetting("updateCheckReleaseUrl", data.html_url);
setSetting("updateCheckLastCheckedAt", new Date().toISOString());
log.info(
`Update check complete: current=${APP_VERSION}, latest=${version}`,
);
return {
updateAvailable: isNewerVersion(version, APP_VERSION),
currentVersion: APP_VERSION,
latestVersion: version,
releaseUrl: data.html_url,
lastCheckedAt: new Date().toISOString(),
};
} catch (err) {
log.warn("Update check failed:", err);
return getCachedUpdateCheck();
}
}