play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
This commit is contained in:
parent
18fde37b9b
commit
a9261a8052
18 changed files with 364 additions and 83 deletions
|
|
@ -1,13 +1,17 @@
|
|||
// 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]
|
||||
// Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*),
|
||||
// phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a
|
||||
// .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Play's images are compared with
|
||||
// the local files first (sha256, in order): only a changed image type is cleared and uploaded, in one edit per
|
||||
// language that is committed whole or not at all, so a re-run changes nothing and never doubles an image. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from
|
||||
// shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API).
|
||||
// Uploads are slow: run in the background with a log file.
|
||||
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]
|
||||
// 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.*),
|
||||
// wearScreenshots (wear/NN.*). play.tablets (default: tablet renders exist) = true makes the two tablet sets required,
|
||||
// false skips them. A missing optional set is an info line, never an abort, and never deletes what Play already has for
|
||||
// that type unless --prune is given (the dry run shows it). A .jpg next to a .png wins (JPEG q92 uploads about 4x
|
||||
// faster; `storebound shots jpeg` makes them). Play's images are compared with the local files first (sha256, in
|
||||
// order): only a changed image type is cleared and uploaded, in one edit per language that is committed whole or not
|
||||
// at all, so a re-run changes nothing and never doubles an image. --variant <id> uploads <renders>/<id>/<lang>/... (an
|
||||
// A/B variant from shots.variants) to the main listing: Play store listing experiments themselves are set up in Play
|
||||
// Console (no API). Uploads are slow: run in the background with a log file.
|
||||
// --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per
|
||||
// language and type, then the edit is deleted. Nothing is uploaded or cleared, not even inside that edit.
|
||||
import { createHash } from 'node:crypto';
|
||||
|
|
@ -19,8 +23,11 @@ import { done, log, step } from '../lib/log';
|
|||
import { playClient } from './client';
|
||||
import { readPlayCopy } from './listing';
|
||||
import { variantRoot } from '../shots/lib';
|
||||
import { resolvedTablets } from '../shots/report';
|
||||
|
||||
export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const;
|
||||
export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' } as const;
|
||||
export const PLAY_TABLET_TYPES: readonly string[] = ['sevenInchScreenshots', 'tenInchScreenshots'];
|
||||
const OPTIONAL_TYPES = ['sevenInchScreenshots', 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots'];
|
||||
|
||||
/** NN.png / NN.jpg in a dir, sorted, JPEG preferred over PNG of the same number. */
|
||||
export function numberedImages(dir: string): string[] {
|
||||
|
|
@ -35,7 +42,9 @@ export function numberedImages(dir: string): string[] {
|
|||
return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!));
|
||||
}
|
||||
|
||||
export interface ImageChange { type: string; change: 'same' | 'replace'; remote: number; local: number }
|
||||
/** same / replace: a local set against Play's. keep / delete / none: an optional set with no local files (Play's images
|
||||
* are kept, deleted with --prune, or Play has none either). */
|
||||
export interface ImageChange { type: string; change: 'same' | 'replace' | 'keep' | 'delete' | 'none'; remote: number; local: number }
|
||||
|
||||
/** One image type: what Play has (sha256 of each uploaded file, in order) against the local files' sha256. Play keeps
|
||||
* the uploaded bytes' hash, so equal lists mean an upload would change nothing. Pure. */
|
||||
|
|
@ -44,79 +53,143 @@ export function imageChange(type: string, remote: { sha256?: string }[], local:
|
|||
return { type, change: same ? 'same' : 'replace', remote: remote.length, local: local.length };
|
||||
}
|
||||
|
||||
/** An optional type with no local files: Play's images stay unless `prune`. Pure. */
|
||||
export function missingChange(type: string, remote: number, prune: boolean): ImageChange {
|
||||
return { type, change: !remote ? 'none' : prune ? 'delete' : 'keep', remote, local: 0 };
|
||||
}
|
||||
|
||||
/** 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 image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */
|
||||
export function langImages(code: string, dir: string, icon: string, tablets: boolean): LangImages {
|
||||
const out: LangImages = { sets: [], missing: [], errors: [] };
|
||||
if (existsSync(icon)) out.sets.push(['icon', [icon]]);
|
||||
else out.errors.push(`${code}: no icon at ${icon} (512x512, required; make it: storebound icons, or set play.icon)`);
|
||||
const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync);
|
||||
if (feature) out.sets.push(['featureGraphic', [feature]]);
|
||||
else out.errors.push(`${code}: no featureGraphic in ${join(dir, 'feature')} (1024x500, required; render first: storebound shots render)`);
|
||||
for (const [type, sub] of Object.entries(PLAY_IMAGE_DIRS)) {
|
||||
const tablet = PLAY_TABLET_TYPES.includes(type);
|
||||
if (tablet && !tablets) { out.missing.push(type); continue; }
|
||||
const files = numberedImages(join(dir, sub)).slice(0, 8);
|
||||
if (!files.length) {
|
||||
if (type === 'phoneScreenshots') out.errors.push(`${code}: no phoneScreenshots in ${join(dir, sub)} (at least 2, required; render first: storebound shots render)`);
|
||||
else if (tablet) out.errors.push(`${code}: play.tablets is true but there are no ${type} in ${join(dir, sub)} (render them: storebound shots render --size tab7,tab10, or set play.tablets: false)`);
|
||||
else out.missing.push(type);
|
||||
continue;
|
||||
}
|
||||
if ((type === 'phoneScreenshots' || tablet) && files.length < 2) out.errors.push(`${code}: ${type} needs at least 2 images (${files.length} in ${join(dir, sub)})`);
|
||||
out.sets.push([type, files]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Info lines for the optional sets no language has locally. Pure. */
|
||||
export function missingInfo(missing: ReadonlySet<string>): string[] {
|
||||
const out: string[] = [];
|
||||
if (PLAY_TABLET_TYPES.some(t => missing.has(t))) out.push("tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them)");
|
||||
if (missing.has('tvScreenshots') && missing.has('wearScreenshots')) out.push('TV / Wear screenshots: none (optional; only for Android TV / Wear OS apps)');
|
||||
else for (const t of ['tvScreenshots', 'wearScreenshots']) if (missing.has(t)) out.push(`${t}: none (optional)`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One language's plan as text: "would replace a (1 -> 2), b (0 -> 2); would delete c (...); keeps d (...)". Pure. */
|
||||
export function describePlan(plan: ImageChange[]): string {
|
||||
const of = (k: ImageChange['change']) => plan.filter(c => c.change === k);
|
||||
const parts = [
|
||||
of('replace').length ? 'would replace ' + of('replace').map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : '',
|
||||
of('delete').length ? 'would delete ' + of('delete').map(c => `${c.type} (${c.remote} on Play, none locally; --prune)`).join(', ') : '',
|
||||
].filter(Boolean);
|
||||
if (!parts.length) parts.push(of('keep').length ? 'nothing to upload' : 'unchanged');
|
||||
for (const c of of('keep')) parts.push(`keeps ${c.type} on Play (${c.remote}, none locally; --prune deletes them)`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root, resolve } = ctx.loaded;
|
||||
const pkg = need(config.play?.packageName, 'play.packageName');
|
||||
const prune = ctx.args.has('prune');
|
||||
const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants);
|
||||
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);
|
||||
const tablets = resolvedTablets(config, src);
|
||||
|
||||
const sets = new Map<string, [string, string[]][]>();
|
||||
const sets = new Map<string, LangImages>();
|
||||
const errors: string[] = [];
|
||||
for (const code of codes) {
|
||||
const l = langOf.get(code);
|
||||
if (!l) throw new Error(`${code}: no entry in languages[] has this Play code`);
|
||||
const dir = join(src, l.id);
|
||||
const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync);
|
||||
const set: [string, string[]][] = [['icon', [icon]], ['featureGraphic', feature ? [feature] : []],
|
||||
...Object.entries(PLAY_IMAGE_DIRS).map(([type, sub]) => [type, numberedImages(join(dir, sub)).slice(0, 8)] as [string, string[]])];
|
||||
for (const [type, files] of set) {
|
||||
if (!files.length || !files.every(existsSync)) throw new Error(`${code}: no ${type} in ${dir} (render first: storebound shots render)`);
|
||||
if (type.endsWith('Screenshots') && files.length < 2) throw new Error(`${code}: ${type} needs at least 2 images`);
|
||||
}
|
||||
sets.set(code, set);
|
||||
const li = langImages(code, join(src, l.id), icon, tablets);
|
||||
errors.push(...li.errors);
|
||||
sets.set(code, li);
|
||||
}
|
||||
log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`);
|
||||
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'}`);
|
||||
for (const line of missingInfo(new Set(OPTIONAL_TYPES.filter(t => [...sets.values()].every(s => s.missing.includes(t)))))) log('play', line);
|
||||
if (config.play?.tablets === false) log('play', 'play.tablets is false: tablet screenshots are not uploaded');
|
||||
|
||||
const play = await playClient(root, pkg);
|
||||
// one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type
|
||||
const remoteOf = async (id: string, code: string, type: string) =>
|
||||
(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)
|
||||
const plan = new Map<string, ImageChange[]>();
|
||||
await play.read(async id => {
|
||||
for (const code of codes) {
|
||||
const out: ImageChange[] = [];
|
||||
for (const [type, files] of sets.get(code)!) {
|
||||
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
|
||||
out.push(imageChange(type, remote, files.map(sha256)));
|
||||
}
|
||||
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));
|
||||
plan.set(code, out);
|
||||
}
|
||||
});
|
||||
const changedTypes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace');
|
||||
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()) {
|
||||
const rep = changedTypes(code);
|
||||
changed += rep.length;
|
||||
log('play', `${step(i + 1, codes.length, code)}: ${rep.length ? 'would replace ' + rep.map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : 'unchanged'}`);
|
||||
changed += writes(code).length;
|
||||
log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}`);
|
||||
}
|
||||
done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : 'every image set is already on Play'}; read-only, nothing uploaded`);
|
||||
done('play', `dry run: ${changed ? changed + ' image set(s) would change' : 'every image set is already on Play'}; read-only, nothing uploaded`);
|
||||
return;
|
||||
}
|
||||
// only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded.
|
||||
// The edit is all or nothing: a failure (or an upload without a clear answer) deletes it, and the redo starts from
|
||||
// what Play has, so a re-run never doubles an image.
|
||||
const todo = codes.filter(code => changedTypes(code).length);
|
||||
for (const code of codes) for (const c of plan.get(code)!.filter(x => x.change === 'keep'))
|
||||
log('play', `${code}: keeps ${c.type} on Play (${c.remote}, none locally; --prune deletes them)`);
|
||||
// only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded
|
||||
// (with --prune also the optional types that have no local files are cleared). The edit is all or nothing: a failure
|
||||
// (or an upload without a clear answer) deletes it, and the redo starts from what Play has, so a re-run never doubles
|
||||
// an image.
|
||||
const todo = codes.filter(code => writes(code).length);
|
||||
if (!todo.length) { done('play', 'every image set is already on Play: nothing uploaded'); return; }
|
||||
for (const [i, code] of todo.entries()) {
|
||||
const set = sets.get(code)!;
|
||||
const li = sets.get(code)!;
|
||||
await play.edit(async (id, ctl) => {
|
||||
let n = 0, types = 0;
|
||||
await Promise.all(set.map(async ([type, files]) => {
|
||||
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
|
||||
if (imageChange(type, remote, files.map(sha256)).change === 'same') return;
|
||||
types++;
|
||||
if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
|
||||
for (const f of files) {
|
||||
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)),
|
||||
{ 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
|
||||
n++;
|
||||
}
|
||||
}));
|
||||
let n = 0, types = 0, deleted = 0;
|
||||
await Promise.all([
|
||||
...li.sets.map(async ([type, files]) => {
|
||||
const remote = await remoteOf(id, code, type);
|
||||
if (imageChange(type, remote, files.map(sha256)).change === 'same') return;
|
||||
types++;
|
||||
if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
|
||||
for (const f of files) {
|
||||
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)),
|
||||
{ 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
|
||||
n++;
|
||||
}
|
||||
}),
|
||||
...(prune ? li.missing.map(async type => {
|
||||
if (!(await remoteOf(id, code, type)).length) return;
|
||||
types++; deleted++;
|
||||
await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
|
||||
}) : []),
|
||||
]);
|
||||
if (!types) ctl.unchanged();
|
||||
log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types} set(s)`);
|
||||
log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types - deleted} set(s)${deleted ? `, ${deleted} set(s) deleted (--prune)` : ''}`);
|
||||
});
|
||||
}
|
||||
done('play', 'images done');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue