iPhone Duo: limit from Apple's reference data instead of a cut to 10, --duo-only; shared asset library helpers in their own module

This commit is contained in:
CRTkafa 2026-11-09 06:35:00 +07:00
parent 5cc3da486d
commit 108b362cd9
7 changed files with 102 additions and 75 deletions

View file

@ -8,18 +8,18 @@
// 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.
// removed and the files placed in order (outer display files first, then inner). Never truncated: more files than
// the limit Apple publishes for the group (refData groupLimits) changes nothing and says so. 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 { classify, pickAsset, processingTiming, REF, selectable } from './asset-library';
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];
/** iPhone placement profile group ids (platform IOS or IPHONE_APP_STORE, as the live API answers) of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
@ -30,26 +30,44 @@ export function duoGroups(refData: J[]): string[] {
return [...ids];
}
/** Apple's screenshot limit for a placement group on a version page, or null when refData publishes none. Pure. */
export function duoLimit(refData: J[], group: string): number | null {
for (const r of refData) for (const f of (r?.attributes?.features ?? []) as J[]) {
if (f?.featureId !== 'APP_STORE_VERSIONS') continue;
for (const p of (f.placementPolicies ?? []) as J[]) {
if (p?.placementType !== 'APP_SCREENSHOT') continue;
for (const l of (p.groupLimits ?? []) as J[]) if ((l?.groupIds ?? []).includes(group) && typeof l.maxCount === 'number') return l.maxCount;
}
}
return null;
}
/** 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> {
export async function duoGroup(c: Asc): Promise<{ id: string; limit: number | null }> {
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]!;
const limit = duoLimit(ref, g[0]!);
log(TAG, `iPhone Duo: placement group ${g[0]}, limit ${limit ?? 'not published'}`);
return { id: g[0]!, limit };
}
/** 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> {
export async function syncDuo(c: Asc, o: { appId: string; group: string; limit: number | null; 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 files = o.files;
if (o.limit !== null && files.length > o.limit) {
warn(TAG, `${tag}: ${files.length} files (outer + inner) but Apple allows ${o.limit} in this group: nothing changed, none dropped. Keep at most ${o.limit} across iphone-duo-outer/ and iphone-duo-inner/`);
process.exitCode = 1;
return { locale, action: 'refuse', files: files.length };
}
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();