mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 23:55:43 -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");
|
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;
|
||||||
|
|||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
// A failed refresh leaves that widget's previous timeline in place; keep its images.
|
||||||
await pruneWidgetImages(IMAGE_PRUNE_MAX_AGE_SECONDS);
|
if (continueWatchingOk && upcomingOk) {
|
||||||
} catch (error) {
|
try {
|
||||||
console.warn("[Widgets] Failed to prune cached images:", error);
|
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(
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user