fix(native): serialize widget refreshes and fall back to other artwork

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 18:02:06 -04:00
1 parent c62ef6b620
commit 69b252e99b
2 files changed
+211 -16

No files matched your search

+152 -2
View File
@@ -92,6 +92,14 @@ async function loadWidgetsModule() {
return import("./widgets"); return import("./widgets");
} }
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
@@ -192,8 +200,24 @@ describe("refreshWidgets", () => {
], ],
}); });
upcoming.mockResolvedValue({
items: [
{
titleId: "show-1",
titleName: "A Show",
titleType: "tv",
date: "2026-03-30",
seasonNumber: 1,
episodeNumber: 2,
episodeCount: 1,
backdropPath: "/bad-backdrop.jpg",
posterPath: "/poster.jpg",
},
],
});
downloadWidgetImage.mockImplementation(async (url: string, key: string) => { downloadWidgetImage.mockImplementation(async (url: string, key: string) => {
if (url.includes("/bad.jpg")) { if (url.includes("/bad")) {
throw new Error("download failed"); throw new Error("download failed");
} }
return `file:///group/${key}`; return `file:///group/${key}`;
@@ -202,11 +226,67 @@ describe("refreshWidgets", () => {
const { refreshWidgets } = await loadWidgetsModule(); const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets(); await refreshWidgets();
// The first item's episode still fails, so it falls back to the show's backdrop.
const entries = continueWatchingWidget.updateTimeline.mock.calls[0]?.[0]; const entries = continueWatchingWidget.updateTimeline.mock.calls[0]?.[0];
expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1); expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1);
expect(entries).toHaveLength(25); expect(entries).toHaveLength(25);
expect(entries[0]?.props?.imageFilePath).toBe(""); expect(entries[0]?.props?.imageFilePath).toMatch(CACHED_WIDGET_IMAGE_RE);
expect(entries[1]?.props?.imageFilePath).toMatch(CACHED_WIDGET_IMAGE_RE); expect(entries[1]?.props?.imageFilePath).toMatch(CACHED_WIDGET_IMAGE_RE);
expect(downloadWidgetImage).toHaveBeenCalledWith(
"https://sofa.test/bad.jpg",
expect.any(String),
);
expect(downloadWidgetImage).toHaveBeenCalledWith(
"https://sofa.test/fallback.jpg",
expect.any(String),
);
// Upcoming falls back from a failed backdrop to the poster.
const upcomingEntries = upcomingWidget.updateTimeline.mock.calls[0]?.[0];
expect(upcomingEntries[0]?.props?.imageFilePath).toMatch(/^file:\/\/\/group\/up_/);
expect(downloadWidgetImage).toHaveBeenCalledWith(
"https://sofa.test/poster.jpg",
expect.any(String),
);
});
test("leaves the image empty when every artwork candidate fails", async () => {
continueWatching.mockResolvedValue({
items: [
{
title: { id: "title-1", title: "The Pitt", backdropPath: "/backdrop.jpg" },
nextEpisode: { seasonNumber: 1, episodeNumber: 5, stillPath: "/still.jpg" },
watchedEpisodes: 4,
totalEpisodes: 12,
},
],
});
upcoming.mockResolvedValue({
items: [
{
titleId: "show-1",
titleName: "A Show",
titleType: "tv",
date: "2026-03-30",
seasonNumber: 1,
episodeNumber: 2,
episodeCount: 1,
backdropPath: "/up-backdrop.jpg",
posterPath: "/up-poster.jpg",
},
],
});
downloadWidgetImage.mockRejectedValue(new Error("download failed"));
const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets();
const entries = continueWatchingWidget.updateTimeline.mock.calls[0]?.[0];
expect(entries[0]?.props).toMatchObject({ titleName: "The Pitt", imageFilePath: "" });
const upcomingEntries = upcomingWidget.updateTimeline.mock.calls[0]?.[0];
expect(upcomingEntries[0]?.props).toMatchObject({ titleName: "A Show", imageFilePath: "" });
// Every candidate was tried before giving up.
expect(downloadWidgetImage).toHaveBeenCalledTimes(4);
}); });
test("passes the copied icon path into empty widget states", async () => { test("passes the copied icon path into empty widget states", async () => {
@@ -390,6 +470,76 @@ describe("resetWidgets", () => {
}); });
}); });
describe("widget refresh ordering", () => {
const watchingItem = {
title: { id: "title-1", title: "Severance", backdropPath: "/backdrop-1.jpg" },
nextEpisode: { seasonNumber: 2, episodeNumber: 3, stillPath: "/still-1.jpg" },
watchedEpisodes: 2,
totalEpisodes: 10,
};
test("a reset waits for an in-flight refresh instead of being overwritten by it", async () => {
const pending = deferred<LibraryResult>();
continueWatching.mockReturnValueOnce(pending.promise);
const { refreshWidgets, resetWidgets } = await loadWidgetsModule();
const refresh = refreshWidgets();
const reset = resetWidgets();
pending.resolve({ items: [watchingItem] });
await Promise.all([refresh, reset]);
expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1);
expect(clearWidgetImages.mock.invocationCallOrder[0]).toBeGreaterThan(
continueWatchingWidget.updateTimeline.mock.invocationCallOrder[0]!,
);
expect(continueWatchingWidget.updateSnapshot).toHaveBeenLastCalledWith(
expect.objectContaining({ titleName: "" }),
);
});
test("refresh requests made while one is waiting share it", async () => {
const { refreshWidgets } = await loadWidgetsModule();
const first = refreshWidgets();
const second = refreshWidgets();
const third = refreshWidgets();
expect(second).toBe(first);
expect(third).toBe(first);
await first;
expect(continueWatching).toHaveBeenCalledTimes(1);
});
test("a request during a running refresh queues exactly one more", async () => {
const pending = deferred<LibraryResult>();
continueWatching.mockReturnValueOnce(pending.promise);
const { refreshWidgets } = await loadWidgetsModule();
const running = refreshWidgets();
await vi.waitFor(() => expect(continueWatching).toHaveBeenCalledTimes(1));
const queued = refreshWidgets();
const alsoQueued = refreshWidgets();
expect(queued).not.toBe(running);
expect(alsoQueued).toBe(queued);
pending.resolve({ items: [] });
await Promise.all([running, queued, alsoQueued]);
expect(continueWatching).toHaveBeenCalledTimes(2);
});
test("does not prune cached images after a widget fails to refresh", async () => {
upcoming.mockRejectedValue(new Error("offline"));
const { refreshWidgets } = await loadWidgetsModule();
await refreshWidgets();
expect(pruneWidgetImages).not.toHaveBeenCalled();
});
});
describe("buildRotationTimeline", () => { describe("buildRotationTimeline", () => {
const HALF_HOUR_MS = 30 * 60 * 1000; const HALF_HOUR_MS = 30 * 60 * 1000;
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000; const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
+59 -14
View File
@@ -46,6 +46,18 @@ async function downloadImage(path: string | null, key: string): Promise<string>
} }
} }
/** Downloads the first of `paths` that succeeds, or returns "" if none do. */
async function downloadFirstImage(
paths: Array<string | null | undefined>,
key: string,
): Promise<string> {
for (const path of new Set(paths.filter((p): p is string => !!p))) {
const file = await downloadImage(path, key);
if (file) return file;
}
return "";
}
const IMAGE_PRUNE_MAX_AGE_SECONDS = 6 * 60 * 60; const IMAGE_PRUNE_MAX_AGE_SECONDS = 6 * 60 * 60;
const WIDGET_ICON_KEY = "sofa_icon.png"; const WIDGET_ICON_KEY = "sofa_icon.png";
@@ -209,10 +221,7 @@ function emptyUpcomingProps(iconFilePath: string): UpcomingProps {
}); });
} }
/** Replace widget content with empty states and delete cached artwork (sign-out / server change). */ async function runReset(): Promise<void> {
export async function resetWidgets(): Promise<void> {
if (Platform.OS !== "ios") return;
const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([ const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([
import("@/widgets/continue-watching"), import("@/widgets/continue-watching"),
import("@/widgets/upcoming"), import("@/widgets/upcoming"),
@@ -230,9 +239,7 @@ export async function resetWidgets(): Promise<void> {
UpcomingWidget.updateSnapshot(emptyUpcomingProps(iconFilePath)); UpcomingWidget.updateSnapshot(emptyUpcomingProps(iconFilePath));
} }
export async function refreshWidgets(): Promise<void> { async function runRefresh(): Promise<void> {
if (Platform.OS !== "ios") return;
// Lazy import to avoid loading widget modules on Android // Lazy import to avoid loading widget modules on Android
const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([ const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([
import("@/widgets/continue-watching"), import("@/widgets/continue-watching"),
@@ -240,28 +247,59 @@ export async function refreshWidgets(): Promise<void> {
]); ]);
const iconFilePath = await ensureIcon(); const iconFilePath = await ensureIcon();
await Promise.all([ const [continueWatchingOk, upcomingOk] = await Promise.all([
refreshContinueWatching(ContinueWatchingWidget, iconFilePath), refreshContinueWatching(ContinueWatchingWidget, iconFilePath),
refreshUpcoming(UpcomingWidget, iconFilePath), refreshUpcoming(UpcomingWidget, iconFilePath),
]); ]);
// A failed refresh leaves that widget's previous timeline in place; keep its images.
if (continueWatchingOk && upcomingOk) {
try { try {
await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS); await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS);
} catch (error) { } catch (error) {
console.warn("[Widgets] Failed to prune cached images:", error); console.warn("[Widgets] Failed to prune cached images:", error);
} }
} }
}
// Refreshes and resets run one at a time, in call order, so a slow refresh can't overwrite
// a newer one or put the previous account's data back after sign-out.
let widgetQueue: Promise<void> = Promise.resolve();
let pendingRefresh: Promise<void> | null = null;
function enqueue(task: () => Promise<void>): Promise<void> {
const run = widgetQueue.then(task);
widgetQueue = run.catch(() => undefined);
return run;
}
/** Replace widget content with empty states and delete cached artwork (sign-out / server change). */
export function resetWidgets(): Promise<void> {
if (Platform.OS !== "ios") return Promise.resolve();
return enqueue(runReset);
}
export function refreshWidgets(): Promise<void> {
if (Platform.OS !== "ios") return Promise.resolve();
// Requests made while a refresh is still waiting its turn share it; one made while a
// refresh is running queues exactly one more.
pendingRefresh ??= enqueue(async () => {
pendingRefresh = null;
await runRefresh();
});
return pendingRefresh;
}
async function refreshContinueWatching( async function refreshContinueWatching(
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"], widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
iconFilePath: string, iconFilePath: string,
): Promise<void> { ): Promise<boolean> {
try { try {
const { items } = await client.library.continueWatching(); const { items } = await client.library.continueWatching();
if (items.length === 0) { if (items.length === 0) {
widget.updateSnapshot(emptyContinueWatchingProps(iconFilePath)); widget.updateSnapshot(emptyContinueWatchingProps(iconFilePath));
return; return true;
} }
const top = items.slice(0, 5); const top = items.slice(0, 5);
@@ -277,7 +315,10 @@ async function refreshContinueWatching(
imagePath, imagePath,
index, index,
]); ]);
const imageFilePath = await downloadImage(imagePath, imageKey); const imageFilePath = await downloadFirstImage(
[item.nextEpisode?.stillPath, item.title.backdropPath],
imageKey,
);
return sanitizeProps<ContinueWatchingProps>({ return sanitizeProps<ContinueWatchingProps>({
titleId: item.title.id, titleId: item.title.id,
@@ -300,15 +341,17 @@ async function refreshContinueWatching(
props: item, props: item,
})), })),
); );
return true;
} catch (error) { } catch (error) {
console.warn("[Widgets] Failed to refresh Continue Watching:", error); console.warn("[Widgets] Failed to refresh Continue Watching:", error);
return false;
} }
} }
async function refreshUpcoming( async function refreshUpcoming(
widget: Awaited<typeof import("@/widgets/upcoming")>["default"], widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
iconFilePath: string, iconFilePath: string,
): Promise<void> { ): Promise<boolean> {
try { try {
const { items } = await client.library.upcoming({ const { items } = await client.library.upcoming({
days: UPCOMING_DAYS, days: UPCOMING_DAYS,
@@ -318,7 +361,7 @@ async function refreshUpcoming(
const timeline = buildUpcomingTimeline(items, new Date()); const timeline = buildUpcomingTimeline(items, new Date());
if (!timeline.some((entry) => entry.item)) { if (!timeline.some((entry) => entry.item)) {
widget.updateSnapshot(emptyUpcomingProps(iconFilePath)); widget.updateSnapshot(emptyUpcomingProps(iconFilePath));
return; return true;
} }
const refreshToken = nextRefreshToken("up"); const refreshToken = nextRefreshToken("up");
@@ -337,7 +380,7 @@ async function refreshUpcoming(
item.backdropPath, item.backdropPath,
index, index,
]); ]);
file = downloadImage(item.backdropPath, imageKey); file = downloadFirstImage([item.backdropPath, item.posterPath], imageKey);
imageFiles.set(item, file); imageFiles.set(item, file);
} }
return file; return file;
@@ -361,7 +404,9 @@ async function refreshUpcoming(
); );
widget.updateTimeline(entries); widget.updateTimeline(entries);
return true;
} catch (error) { } catch (error) {
console.warn("[Widgets] Failed to refresh Upcoming:", error); console.warn("[Widgets] Failed to refresh Upcoming:", error);
return false;
} }
} }