mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
2 files changed
+215
-20
No files matched your search
@@ -92,6 +92,14 @@ async function loadWidgetsModule() {
|
||||
return import("./widgets");
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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) => {
|
||||
if (url.includes("/bad.jpg")) {
|
||||
if (url.includes("/bad")) {
|
||||
throw new Error("download failed");
|
||||
}
|
||||
return `file:///group/${key}`;
|
||||
@@ -202,11 +226,67 @@ describe("refreshWidgets", () => {
|
||||
const { refreshWidgets } = await loadWidgetsModule();
|
||||
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];
|
||||
expect(continueWatchingWidget.updateTimeline).toHaveBeenCalledTimes(1);
|
||||
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(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 () => {
|
||||
@@ -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", () => {
|
||||
const HALF_HOUR_MS = 30 * 60 * 1000;
|
||||
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
@@ -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 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). */
|
||||
export async function resetWidgets(): Promise<void> {
|
||||
if (Platform.OS !== "ios") return;
|
||||
|
||||
async function runReset(): Promise<void> {
|
||||
const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([
|
||||
import("@/widgets/continue-watching"),
|
||||
import("@/widgets/upcoming"),
|
||||
@@ -230,9 +239,7 @@ export async function resetWidgets(): Promise<void> {
|
||||
UpcomingWidget.updateSnapshot(emptyUpcomingProps(iconFilePath));
|
||||
}
|
||||
|
||||
export async function refreshWidgets(): Promise<void> {
|
||||
if (Platform.OS !== "ios") return;
|
||||
|
||||
async function runRefresh(): Promise<void> {
|
||||
// Lazy import to avoid loading widget modules on Android
|
||||
const [{ default: ContinueWatchingWidget }, { default: UpcomingWidget }] = await Promise.all([
|
||||
import("@/widgets/continue-watching"),
|
||||
@@ -240,28 +247,59 @@ export async function refreshWidgets(): Promise<void> {
|
||||
]);
|
||||
|
||||
const iconFilePath = await ensureIcon();
|
||||
await Promise.all([
|
||||
const [continueWatchingOk, upcomingOk] = await Promise.all([
|
||||
refreshContinueWatching(ContinueWatchingWidget, iconFilePath),
|
||||
refreshUpcoming(UpcomingWidget, iconFilePath),
|
||||
]);
|
||||
|
||||
try {
|
||||
await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS);
|
||||
} catch (error) {
|
||||
console.warn("[Widgets] Failed to prune cached images:", error);
|
||||
// A failed refresh leaves that widget's previous timeline in place; keep its images.
|
||||
if (continueWatchingOk && upcomingOk) {
|
||||
try {
|
||||
await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS);
|
||||
} catch (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(
|
||||
widget: Awaited<typeof import("@/widgets/continue-watching")>["default"],
|
||||
iconFilePath: string,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { items } = await client.library.continueWatching();
|
||||
|
||||
if (items.length === 0) {
|
||||
widget.updateSnapshot(emptyContinueWatchingProps(iconFilePath));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const top = items.slice(0, 5);
|
||||
@@ -277,7 +315,10 @@ async function refreshContinueWatching(
|
||||
imagePath,
|
||||
index,
|
||||
]);
|
||||
const imageFilePath = await downloadImage(imagePath, imageKey);
|
||||
const imageFilePath = await downloadFirstImage(
|
||||
[item.nextEpisode?.stillPath, item.title.backdropPath],
|
||||
imageKey,
|
||||
);
|
||||
|
||||
return sanitizeProps<ContinueWatchingProps>({
|
||||
titleId: item.title.id,
|
||||
@@ -300,15 +341,17 @@ async function refreshContinueWatching(
|
||||
props: item,
|
||||
})),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[Widgets] Failed to refresh Continue Watching:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUpcoming(
|
||||
widget: Awaited<typeof import("@/widgets/upcoming")>["default"],
|
||||
iconFilePath: string,
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { items } = await client.library.upcoming({
|
||||
days: UPCOMING_DAYS,
|
||||
@@ -318,7 +361,7 @@ async function refreshUpcoming(
|
||||
const timeline = buildUpcomingTimeline(items, new Date());
|
||||
if (!timeline.some((entry) => entry.item)) {
|
||||
widget.updateSnapshot(emptyUpcomingProps(iconFilePath));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const refreshToken = nextRefreshToken("up");
|
||||
@@ -337,7 +380,7 @@ async function refreshUpcoming(
|
||||
item.backdropPath,
|
||||
index,
|
||||
]);
|
||||
file = downloadImage(item.backdropPath, imageKey);
|
||||
file = downloadFirstImage([item.backdropPath, item.posterPath], imageKey);
|
||||
imageFiles.set(item, file);
|
||||
}
|
||||
return file;
|
||||
@@ -361,7 +404,9 @@ async function refreshUpcoming(
|
||||
);
|
||||
|
||||
widget.updateTimeline(entries);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[Widgets] Failed to refresh Upcoming:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user