play: a new language is created with everything Play needs to commit it (icon, feature graphic, phone and tablet screenshots); play images creates a missing listing

This commit is contained in:
CRTkafa 2026-10-24 19:46:57 +08:00
parent 2ec1f0ab13
commit ed8d138a8e
7 changed files with 172 additions and 32 deletions

View file

@ -2,8 +2,9 @@
// language, not of the whole upload).
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] [--not-sent-for-review]
// 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.
// --include-drafts). A language Play has no listing for yet (404 "Listing for language ... not found") is created by
// this run from its text in the listing file, in the same edit as all its images (Play commits a new language only with
// icon, feature graphic and 2+ phone screenshots in it; `play listing` does the same, from either command).
// 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.*),
@ -26,7 +27,7 @@ import { draftReason, draftSections } from '../lib/listing-plan';
import { done, log, step } from '../lib/log';
import { playClient } from './client';
import { pulledChecksum } from '../lib/art-pull';
import { readPlayCopy } from './listing';
import { listingBody, readPlayCopy } from './listing';
import { variantRoot } from '../shots/lib';
import { resolvedTablets } from '../shots/report';
import { consoleLink } from '../lib/console-links';
@ -72,16 +73,34 @@ export function missingChange(type: string, remote: number, prune: boolean): Ima
/** What one language uploads: the sets with files, the optional types without, and errors (missing required sets). */
export interface LangImages { sets: [string, string[]][]; missing: string[]; errors: string[] }
/** The graphics a NEW Play language needs before its edit can commit (403 "no high resolution icon set for language"):
* the hi-res icon and, when rendered, the feature graphic. `play listing` uploads them in the same edit as the text,
* so listing and images never wait for each other (pitfall 102). */
export function firstGraphics(ctx: Ctx, code: string): [string, string][] {
/** Everything Play wants in the same edit that creates a NEW language, or the commit is refused (403 "no high
* resolution icon set for language X" / "too few screenshots for language X"): icon, feature graphic, at least 2 phone
* screenshots and, when the app's other languages have them (or play.tablets is true), the two tablet sets. A set the
* language's renders folder lacks falls back to the default language's (a note); what is still missing is an error
* with the exact fix, raised in the dry run, never at commit time (pitfall 102). */
export interface NewLangImages { code: string; uploads: [string, string[]][]; notes: string[]; errors: string[] }
export function newLanguageImages(ctx: Ctx, code: string, defaultCode: string, tabletsOnPlay: boolean): NewLangImages {
const { config, resolve } = ctx.loaded;
const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png'));
const l = byPlayCode(config.languages).get(code);
const src = resolve(config.renders ?? config.shots?.out ?? 'store/renders');
const li = langImages(code, l ? join(src, l.id) : join(src, '__none__'), icon, false);
return li.sets.filter(([t]) => t === 'icon' || t === 'featureGraphic').map(([t, files]) => [t, files[0]!] as [string, string]);
const langOf = byPlayCode(config.languages);
const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants);
const tablets = config.play?.tablets === undefined ? resolvedTablets(config, src) || tabletsOnPlay : config.play.tablets;
const dirOf = (c: string) => join(src, langOf.get(c)?.id ?? '__none__');
const own = langImages(code, dirOf(code), icon, tablets);
const def = code === defaultCode ? null : langImages(defaultCode, dirOf(defaultCode), icon, tablets);
const required = ['icon', 'featureGraphic', 'phoneScreenshots', ...(tablets ? PLAY_TABLET_TYPES : [])];
const sets = new Map(own.sets);
const notes: string[] = [], errors: string[] = [];
for (const type of required) {
const mine = sets.get(type), fb = def?.sets.find(([t]) => t === type)?.[1];
const short = type === 'phoneScreenshots' || PLAY_TABLET_TYPES.includes(type);
if (mine && (!short || mine.length >= 2)) continue;
if (fb && (!short || fb.length >= 2)) { sets.set(type, fb); notes.push(`${code}: no ${type} of its own, using ${defaultCode}'s (${fb.length}); render ${code}'s with storebound shots render`); continue; }
const why = own.errors.find(e => e.includes(type === 'icon' ? 'no icon' : type)) ?? `${code}: ${type} missing`;
errors.push(`${why}. Play refuses to commit a new language without it (403 too few screenshots / no high resolution icon)`);
}
const uploads = [...sets].filter(([t, f]) => f.length && (tablets || !PLAY_TABLET_TYPES.includes(t)));
return { code, uploads, notes, errors };
}
/** The image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */
@ -180,7 +199,7 @@ 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
// a language with no listing on Play yet answers 404: created in its edit (below), never fatal
const plan = new Map<string, ImageChange[]>();
const unlisted: string[] = [];
await play.read(async id => {
@ -197,14 +216,21 @@ export async function run(ctx: Ctx): Promise<void> {
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)`);
// a language with text in the listing file but no Play listing yet: this run creates it (the default text of the file)
// together with all its images in one edit, which Play only commits as a whole (pitfall 102)
const copyOf = new Map(readPlayCopy(ctx).map(c => [c.lang, c]));
for (const code of unlisted) {
const li = sets.get(code)!;
plan.set(code, [...li.sets.map(([type, files]) => imageChange(type, [], files.map(storeSha))), ...li.missing.map(t => missingChange(t, 0, prune))]);
log('play', `${code}: no Play listing yet, this run creates it from the listing file with its images`);
}
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;
for (const [i, code] of codes.entries()) {
changed += writes(code).length;
log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}`);
log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}${unlisted.includes(code) ? ' (and creates the listing)' : ''}`);
}
done('play', `dry run: ${changed ? changed + ' image set(s) would change' : 'every image set is already on Play'}; read-only, nothing uploaded`);
return;
@ -221,6 +247,7 @@ export async function run(ctx: Ctx): Promise<void> {
const li = sets.get(code)!;
await play.edit(async (id, ctl) => {
let n = 0, types = 0, deleted = 0;
if (unlisted.includes(code)) await play.call('PUT', `${play.API}/edits/${id}/listings/${code}`, listingBody(copyOf.get(code)!, config.play?.video));
await Promise.all([
...li.sets.map(async ([type, files]) => {
const remote = await remoteOf(id, code, type);

View file

@ -20,7 +20,7 @@ import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, step, warn } from '../lib/log';
import { playClient } from './client';
import { firstGraphics } from './images';
import { newLanguageImages, type NewLangImages } from './images';
import { stdinIsTTY } from '../lib/tty';
/** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */
@ -140,12 +140,40 @@ export async function run(ctx: Ctx): Promise<void> {
if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`);
const play = await playClient(root, pkg);
const bodies = copy.map(c => listingBody(c, config.play?.video));
// A language Play does not have yet only commits with its icon, feature graphic, 2+ phone screenshots (and the
// tablet sets when the other languages have them) in the same edit: planned here so the dry run refuses early.
const fresh = new Map<string, NewLangImages>();
const planFresh = async (id: string, now: PlayListing[]) => {
const missing = bodies.filter(b => !now.some(n => n.language === b.language)).map(b => b.language!);
if (!missing.length) return;
const ref = now.find(n => n.language === details.defaultLanguage) ?? now[0];
let tabletsOnPlay = false;
if (ref) for (const t of ['sevenInchScreenshots', 'tenInchScreenshots']) {
try { tabletsOnPlay ||= !!((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${id}/listings/${ref.language}/${t}`)).images?.length); } catch { /* no tablet set */ }
}
for (const code of missing) {
const f = newLanguageImages(ctx, code, details.defaultLanguage, tabletsOnPlay);
// the very first listing of an app: Play checks the images at release, not at this commit, so only a warning
if (!now.length && f.errors.length) { f.notes.push(...f.errors.map(e => `warning: ${e}`)); f.errors = []; }
fresh.set(code, f);
}
};
const p = await play.read(async id => {
const cur = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
await planFresh(id, now);
return playPlan(cur, details, now, bodies, { skipped, notes, only: a.has('only') });
});
for (const f of fresh.values()) {
p.notes.push(...f.notes);
if (!f.errors.length) p.notes.push(`${f.code} is new on Play: uploads ${f.uploads.map(([t, fl]) => `${t} (${fl.length})`).join(', ')} in the same edit`);
}
const dry = a.has('dry');
const refuse = [...fresh.values()].flatMap(f => f.errors);
if (refuse.length) {
if (a.has('json')) printJson({ dry, ...p, errors: refuse }); else for (const line of playPlanLines(p)) console.log(line);
throw new Error(['a new Play language needs its images in the same edit:', ...refuse].join('\n'));
}
if (a.has('json')) printJson({ dry, ...p });
else for (const line of playPlanLines(p)) console.log(line);
if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
@ -168,13 +196,11 @@ export async function run(ctx: Ctx): Promise<void> {
for (const [i, b] of todo.entries()) {
await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b);
log('play', step(i + 1, todo.length, b.language!));
// a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102)
// a language Play does not have yet: its edit only commits with its images in it (pitfall 102)
if (!now.some(n => n.language === b.language)) {
const graphics = firstGraphics(ctx, b.language!);
if (!graphics.some(([t]) => t === 'icon')) warn('play', `${b.language}: no 512x512 icon to send with this new language (play.icon or store/icons/play-512.png; storebound icons makes it): Play may refuse the commit with 403 "no high resolution icon"`);
for (const [type, f] of graphics) {
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
log('play', `${b.language}: ${type} uploaded with the new listing`);
for (const [type, files] of fresh.get(b.language!)?.uploads ?? []) {
for (const f of files) await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
log('play', `${b.language}: ${type} (${files.length}) uploaded with the new listing`);
}
}
}