Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions src/components/webhook-loop-mount.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useRef } from "react";
import { useAuth } from "@/lib/auth";
import { useSettings } from "@/lib/settings";
import { proactiveStorageCleanup } from "@/lib/storage-recovery";
import { runWebhookTick } from "@/lib/webhook-engine";

const TICK_INTERVAL_MS = 30 * 60 * 1000;
Expand All @@ -17,10 +16,6 @@ export function WebhookLoopMount() {
authRef.current = authKey;
const runningRef = useRef(false);

useEffect(() => {
proactiveStorageCleanup();
}, []);

useEffect(() => {
const hasDestination =
!!settings.webhooks.discordUrl ||
Expand Down
8 changes: 6 additions & 2 deletions src/lib/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { setItemWithRecovery } from "@/lib/storage-recovery";

type Flags = { onboarded: boolean; nudges: Record<string, boolean> };

Expand All @@ -31,7 +32,7 @@ export function parseOnboardingFlags(raw: string | null): Flags {
return {
...DEFAULT,
...parsed,
nudges: { [CONTENT_ADVISORY_NUDGE]: true, ...(parsed.nudges ?? {}) },
nudges: { [CONTENT_ADVISORY_NUDGE]: true, ...parsed.nudges },
};
} catch {
return DEFAULT;
Expand All @@ -46,7 +47,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
);

useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(flags));
// This provider wraps the whole app, so a bare setItem throwing here (a full store
// throws on every write) takes the tree down during commit, before anything can
// signal the boot splash to clear. Prune and keep going instead.
setItemWithRecovery(STORAGE_KEY, JSON.stringify(flags));
}, [flags]);

const finishOnboarding = useCallback(() => setFlags((f) => ({ ...f, onboarded: true })), []);
Expand Down
139 changes: 90 additions & 49 deletions src/lib/storage-recovery.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,63 @@
const PRUNABLE_EXACT = new Set<string>([
"harbor.omdb.v1",
// What may be evicted when the store is full. Prunable means the network or a recomputation can
// give it back; anything the user made or did stays, however big it is: playback history, last
// season, live-TV watch counts and manual EPG matches, Discover's learned affinity, uploaded
// stream badges and their rules, the legacy parental pin, the build rating, completed library
// repairs, webhook delivery history, addon velocity history, and OMDb's daily request
// budget. Open the module that
// owns a key before adding it here - backup.ts groups by prefix, so its sections mix real records
// with caches and cannot answer this on their own. Keys of a few KB are not worth the risk either
// way; this list is for what actually fills the store.
//
// These are prefixes, and stop before any version stamp on purpose. Pinning a version means the
// live cache silently stops being prunable the day its owner bumps it, which is how seven of
// these were unreachable until now.
const PRUNABLE_PREFIXES = [
// Only OMDb's rating and negative-result caches are rebuildable. The daily
// request budget and key-validity state must survive recovery.
"harbor.omdb.v",
"harbor.omdb.misses",
"harbor.omdb.budget",
"harbor.iptv.hydration.v2",
"harbor.iptv.epgmap.v1",
"harbor.iptv.stats.v1",
"harbor.dead-streams.v1",
"harbor.discover.v1",
"harbor.discover.events.v1",
"harbor.discover.awards.v1",
"harbor.discover.rows.v1",
"harbor.scroll.v1",
"harbor.iptv.hydration.v",
"harbor.dead-streams.v",
"harbor.discover.events.v",
"harbor.discover.awards.v",
"harbor.discover.rows.v",
"harbor.scroll.v",
"harbor.webhook.lastTick",
"harbor.calendar.webhook.last",
"harbor.anidbtvdbcache",
"harbor.armcache",
"harbor.armkitsucache",
"harbor.armsrcmalcache",
"harbor.extkitsucache",
"harbor.imdb.tmdb.v1",
"harbor.tmdb.imdb.v1",
"harbor.tmdb.personName.v1",
"harbor.awards.wikidata.v8",
"harbor.awards.wikidata.v9",
"harbor.awards.wikidata.v10",
"harbor.anime_awards.metas.v2",
"harbor.animefillercache.v2",
"harbor.jikancatalog",
"harbor.imdb.tmdb.v",
"harbor.tmdb.imdb.v",
"harbor.tmdb.personName.v",
"harbor.awards.wikidata.v",
"harbor.graph.wikidata.",
"harbor.anime_awards.metas.",
"harbor.animefillercache.v",
"harbor.malscorecache",
"harbor.anime.recs_by_mal.v1",
"harbor.anime.toppicks.cache.v1",
"harbor.anime.toppicks.shown.v1",
"harbor.anime.toppicks.visit.v1",
"harbor.anime.mal_id_by_franchise.v1",
"harbor.picker-cache.v4",
"harbor.picker-cache.v5",
"harbor.shows.hero.pool.v2",
"harbor.anime.recs_by_mal.v",
"harbor.anime.toppicks.cache.",
"harbor.anime.mal_id_by_franchise.v",
"harbor.anime.art.v",
"harbor.anime.hero.hosted.v",
"harbor.anilist.collection.v",
"harbor.shows.hero.pool.v",
"harbor.heroPool.v",
"harbor.mal.hero.v",
"harbor.mdblist.cards",
"harbor.build.rating.v1",
"harbor.parental",
"harbor.lastseason.v1",
"harbor.surprise.recent.v1",
"harbor.stremio-addons.velocity.v1",
"harbor.playback-history.v1",
"harbor.streamBadges.v1",
"harbor.manga.popular.v1",
"harbor.ebook.metadata.v1",
"harbor.heroPool.v1",
]);

const PRUNABLE_PREFIXES = [
"harbor.libraryNameRepair.v1.",
"harbor.anilist.collection.v1.",
"harbor.manga.cache.v1.",
"harbor.manga.cache.v2.",
"harbor.manga.popular.v",
"harbor.manga.cache.v",
"harbor.manga.art.",
"harbor.ebook.openlibrary.v1.",
"harbor.ebook.metadata.v",
"harbor.ebook.nyt.v",
"harbor.ebook.openlibrary.v",
"harbor.sports.board.v",
"harbor.tvdbo.",
"harbor.playback-history.v1.",
];

function isPrunable(key: string): boolean {
if (PRUNABLE_EXACT.has(key)) return true;
return PRUNABLE_PREFIXES.some((p) => key.startsWith(p));
}

Expand Down Expand Up @@ -256,8 +252,51 @@ function purgeOldCacheVersions(): void {
}
}

const PROBE_KEY = "harbor.storage.probe";
/** Room for one more character proves nothing: the next boot write is a settings or profile
* blob, so ask for enough space to land one of those. */
const PROBE_CHARS = 32 * 1024;

/**
* The size thresholds below only measure what is prunable, so a store filled mostly by
* keys we never prune stays under them while every write already throws. Ask the store
* itself instead.
*/
function storageRejectsWrites(): boolean {
try {
localStorage.setItem(PROBE_KEY, "x".repeat(PROBE_CHARS));
localStorage.removeItem(PROBE_KEY);
return false;
} catch (e) {
localStorage.removeItem(PROBE_KEY);
return isQuotaError(e);
}
}

/**
* Boot only: the store cannot take a realistic write, so free until it can and stop there.
* Dropping every cache instead would cost the user a full refetch of the home feed to buy
* headroom nothing is asking for.
*/
function freeUntilWritable(): void {
for (const { key } of prunableEntries()) {
pruneOne(key);
if (!storageRejectsWrites()) return;
}
}

export function proactiveStorageCleanup(): void {
if (typeof localStorage === "undefined") return;
// Runs before createRoot, so a storage that is blocked or corrupt rather than merely full
// must not reject mount() - that leaves the boot splash up with nothing behind it.
try {
pruneUntilUsable();
} catch (e) {
console.warn("[storage] proactive cleanup skipped", e);
}
}

function pruneUntilUsable(): void {
purgeDeadCaches();
purgeOldCacheVersions();
capLruPrefixes();
Expand All @@ -272,5 +311,7 @@ export function proactiveStorageCleanup(): void {
console.info(
`[storage] proactive total cleanup: ${r.pruned.length} caches, ${r.freedBytes} bytes`,
);
} else if (storageRejectsWrites()) {
freeUntilWritable();
}
}
10 changes: 10 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import "@/index.css";
import "flag-icons/css/flag-icons.min.css";
import { startTaskbarProgress } from "@/lib/download/taskbar-progress";
import { completeBetaReturnPreferences } from "@/lib/updater/beta-return";
import { proactiveStorageCleanup } from "@/lib/storage-recovery";

// Loaded only when a DJ window is actually opened; not part of the core entry graph.
const DjDeckApp = lazy(() =>
Expand Down Expand Up @@ -220,7 +221,16 @@ function MainRoot() {
return <App onReady={markAppReady} />;
}

/** PIP, modal, captions, HDR overlay, DJ deck and the detached player all load this same
* index.html and share its localStorage. Opening one must not evict caches the main window
* is in the middle of using. */
const isAuxWindow = isPip || isVideoPip || isModal || isHdrOverlay || isCaptions || isDjDeck;

async function mount() {
// Must run before anything writes: on a full localStorage every setItem throws, and the
// first unguarded one during the initial render kills the tree before onReady fires, so
// the boot splash never clears. Cleaning up from inside a component is too late.
if (!isAuxWindow) proactiveStorageCleanup();
await loadInstallFeatures();
const musicEnabled = getInstallFeatures().music;
if (
Expand Down
Loading
Loading