mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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> {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
|
||||
export const updateToastShownAtom = atomWithStorage<boolean>(
|
||||
"sofa:update-toast-shown",
|
||||
false,
|
||||
createJSONStorage(() => sessionStorage),
|
||||
);
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ const JOB_NAMES = [
|
||||
"refreshTvChildren",
|
||||
"cacheImages",
|
||||
"scheduledBackup",
|
||||
"updateCheck",
|
||||
];
|
||||
|
||||
function getDatabaseHealth(): SystemHealthData["database"] {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user