play images: same languages as play listing (drafts skipped unless --include-drafts); a language with no Play listing (404) is skipped with an info line, never fatal

This commit is contained in:
CRTkafa 2026-10-12 08:40:27 +08:00
parent 147cee9721
commit 720cb2a13a
6 changed files with 71 additions and 7 deletions

View file

@ -1063,7 +1063,7 @@
"storebound play data-safety [--csv out.csv] [--dry] [--force]"
],
"play images": [
"storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]"
"storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts]"
],
"play listing": [
"storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]",

View file

@ -1,6 +1,9 @@
// Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that
// language, not of the whole upload).
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts]
// Languages: the ones `play listing` pushes (a status=draft or status=translate section is skipped unless
// --include-drafts) and that have a listing on Play. A language Play has no listing for yet (404 "Listing for language
// ... not found") is skipped with an info line, never an error: run `play listing` first.
// Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*) and
// phoneScreenshots (play/NN.*, at least 2) are required (Play Console Help answer 9866151). Optional, uploaded only
// when present: sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*), tvScreenshots (tv/NN.*),
@ -19,6 +22,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { byPlayCode, need } from '../lib/ctx';
import { draftReason, draftSections } from '../lib/listing-plan';
import { done, log, step } from '../lib/log';
import { playClient } from './client';
import { readPlayCopy } from './listing';
@ -106,6 +110,9 @@ export function describePlan(plan: ImageChange[]): string {
return parts.join('; ');
}
/** A Play 404 for a language with no store listing ("Listing for language 'de-DE' not found."). Pure. */
export const noListing = (e: unknown): boolean => /-> 404\b/.test(String((e as Error)?.message ?? e)) && /listing/i.test(String((e as Error)?.message ?? e));
const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex');
export async function run(ctx: Ctx): Promise<void> {
@ -116,7 +123,15 @@ export async function run(ctx: Ctx): Promise<void> {
if (ctx.args.get('variant')) log('play', `variant ${ctx.args.get('variant')}: ${src}`);
const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png'));
const langOf = byPlayCode(config.languages);
const codes = readPlayCopy(ctx).map(c => c.lang);
// the languages `play listing` pushes: a draft or untranslated section has no Play listing yet (unless --include-drafts)
const drafts = draftSections(readFileSync(resolve(config.play?.listing ?? 'store/listing.md'), 'utf8'), 'play');
const skipped: string[] = [];
let codes = readPlayCopy(ctx).map(c => c.lang).filter(code => {
const st = drafts.get(code);
if (!st || ctx.args.has('include-drafts')) return true;
skipped.push(`skipped ${code}: no Play listing yet (${draftReason(st)})`);
return false;
});
const tablets = resolvedTablets(config, src);
const sets = new Map<string, LangImages>();
@ -128,6 +143,7 @@ export async function run(ctx: Ctx): Promise<void> {
errors.push(...li.errors);
sets.set(code, li);
}
for (const line of skipped) log('play', line);
if (errors.length) throw new Error(errors.join('\n'));
const first = [...sets.values()][0];
log('play', `${codes.length} languages: ${first?.sets.map(([t, f]) => `${t} (${f.length})`).join(', ') ?? 'nothing'}`);
@ -139,15 +155,25 @@ export async function run(ctx: Ctx): Promise<void> {
(await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
// one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type;
// for an optional type with no local files only how many Play has (kept, or deleted with --prune)
// a language with no listing on Play yet answers 404: skipped (play listing creates it), never fatal
const plan = new Map<string, ImageChange[]>();
const unlisted: string[] = [];
await play.read(async id => {
for (const code of codes) {
const li = sets.get(code)!, out: ImageChange[] = [];
for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256)));
for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune));
try {
for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256)));
for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune));
} catch (e) {
if (!noListing(e)) throw e;
unlisted.push(code);
continue;
}
plan.set(code, out);
}
});
for (const code of unlisted) log('play', `skipped ${code}: no Play listing yet (run storebound play listing first; a draft section needs review or --include-drafts)`);
codes = codes.filter(c => plan.has(c));
const writes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace' || c.change === 'delete');
if (ctx.args.has('dry')) {
let changed = 0;