storebound/cli/apple/duo-library.ts

113 lines
8.6 KiB
TypeScript

// iPhone Duo screenshots through the App Asset Library (App Store Connect API 4.5.1, pitfall 130). ScreenshotDisplayType
// has no Duo value and never will: Apple added the display class IPHONE_DUO only to AppAssetLibraryDisplayClass and
// deprecated appScreenshotSets / appScreenshots in the same release. Flow:
// 1. GET /v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT -> the iOS placementProfileGroup whose
// displayClassId is IPHONE_DUO (its id is the placementGroup of every Duo screenshot);
// 2. each file is uploaded once to the app's library (POST appAssetLibraryImages, category
// APP_SCREENSHOTS_AND_PREVIEWS, referenceName "storebound <md5>", the same MD5 reuse as creative assets);
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
// removed and the files placed in order (outer display files first, then inner; at most 10). Read back at the end.
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { log, warn } from '../lib/log';
import { elapsed } from '../lib/progress';
import { classify, pickAsset, processingTiming, REF, selectable } from './creative-assets';
import { type Asc, md5, type J } from './asc';
import { partHeaders } from './screenshot-health';
const TAG = 'apple';
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
export const DUO_MAX = 10;
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
/** iOS placement profile group ids of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
export function duoGroups(refData: J[]): string[] {
const ids = new Set<string>();
for (const r of refData) for (const g of (r?.attributes?.placementProfileGroups ?? []) as J[])
if (g?.displayClassId === 'IPHONE_DUO' && (!g.platform || g.platform === 'IOS') && g.placementProfileGroupId) ids.add(String(g.placementProfileGroupId));
return [...ids];
}
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
export async function duoGroup(c: Asc): Promise<string> {
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
const g = duoGroups(ref);
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
return g[0]!;
}
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
export async function syncDuo(c: Asc, o: { appId: string; group: string; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
const locale: string = o.loc.attributes.locale;
const tag = `${locale} IPHONE_DUO`;
const files = o.files.slice(0, DUO_MAX);
if (o.files.length > DUO_MAX) warn(TAG, `${tag}: ${o.files.length} files, only the first ${DUO_MAX} are used`);
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
let assets = await library();
const want = files.map(f => md5(readFileSync(f)));
const placedAll = await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`);
const placed = placedAll.filter(p => p.attributes?.placementGroup === o.group);
const shown = placed.map(p => sumOf(assets.find(a => a.id === p.relationships?.image?.data?.id)));
if (duoUpToDate(shown, want)) { log(TAG, `${tag}: ${files.length} up to date`); return { locale, action: 'keep', files: files.length }; }
if (o.dry) {
const up = want.filter(s => !pickAsset(assets, s).asset).length;
log(TAG, `${tag}: would ${placed.length ? `replace ${placed.length} placed` : 'place'} ${files.length} screenshot(s) through the Asset Library (${up} to upload)`);
return { locale, action: 'plan', files: files.length };
}
const chosen: J[] = [];
for (let i = 0; i < files.length; i++) {
const f = files[i]!, sum = want[i]!;
const pick = pickAsset(assets, sum);
if (pick.refuse) { warn(TAG, `${tag} ${basename(f)}: ${pick.refuse}`); return { locale, action: 'refuse', files: files.length }; }
let a = pick.asset;
if (!a) { log(TAG, `${tag}: uploading ${basename(f)} to the Asset Library`); a = await uploadImage(c, lib.id, f, sum); assets = await library(); a = pickAsset(assets, sum).asset ?? a; }
else if (pick.unarchive) a = (await c.asc('PATCH', `/v1/${a.type}/${a.id}`, { data: { type: a.type, id: a.id, attributes: { archived: false } } })).data as J;
a = await processed(c, a, `${tag} ${basename(f)}`);
const cls = classify(a.attributes?.state);
if (cls === 'processing') return { locale, action: 'wait', files: files.length };
if (!selectable(cls)) { warn(TAG, `${tag} ${basename(f)}: ${a.attributes?.state}; nothing changed`); return { locale, action: 'refuse', files: files.length }; }
chosen.push(a);
}
// every file is ready before the old placements go, so a failed upload leaves the page as it was
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
for (const a of chosen) await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: 'APP_SCREENSHOT', placementGroup: o.group },
relationships: { image: { data: { type: 'appAssetLibraryImages', id: a.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: o.loc.id } } } } });
const now = (await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`)).filter(p => p.attributes?.placementGroup === o.group);
const ids = now.map(p => p.relationships?.image?.data?.id);
if (ids.length !== chosen.length || chosen.some(a => !ids.includes(a.id))) { warn(TAG, `${tag}: read back ${ids.length} placements, wanted ${chosen.length}`); process.exitCode = 1; }
else log(TAG, `${tag}: placed ${chosen.length} (removed ${placed.length})`);
return { locale, action: 'place', files: chosen.length };
}
async function uploadImage(c: Asc, libId: string, file: string, sum: string): Promise<J> {
const buf = readFileSync(file);
const type = 'appAssetLibraryImages';
const res = (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: basename(file), fileSize: buf.length, category: DUO_CATEGORY, referenceName: REF(sum) },
relationships: { assetLibrary: { data: { type: 'appAssetLibraries', id: libId } } } } })).data as J;
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
const r = await fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) });
if (r.status < 200 || r.status > 299) { await c.asc('DELETE', `/v1/${type}/${res.id}`).catch(() => {}); throw new Error(`upload ${basename(file)} part ${op.offset} -> ${r.status} (reservation deleted, nothing committed)`); }
}
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
}
async function processed(c: Asc, a: J, label: string): Promise<J> {
const t0 = Date.now();
let beat = t0, cur = a;
while (classify(cur.attributes?.state) === 'processing') {
if (Date.now() - t0 >= processingTiming.timeoutMs) { warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}; re-run later to place it`); return cur; }
await processingTiming.sleep(processingTiming.pollMs);
cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur;
if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); }
}
return cur;
}