// 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] [--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 //: 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 uploads ///... (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'; 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 { pulledChecksum } from '../lib/art-pull'; 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', 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[] { if (!existsSync(dir)) return []; const files = readdirSync(dir).filter(f => /^\d+\.(png|jpe?g)$/i.test(f)); const byNum = new Map(); for (const f of files.sort()) { const n = f.split('.')[0]!; const cur = byNum.get(n); if (!cur || /\.jpe?g$/i.test(f)) byNum.set(n, f); } return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!)); } /** 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. */ export function imageChange(type: string, remote: { sha256?: string }[], local: string[]): ImageChange { const same = remote.length === local.length && remote.every((r, i) => r.sha256 === local[i]); 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[] { 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('; '); } /** 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'); /** sha256 for the compare with Play: a file still exactly as `art pull` saved it counts with Play's own sha256. */ const storeSha = (f: string): string => pulledChecksum(f, 'play') ?? sha256(f); export async function run(ctx: Ctx): Promise { 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); // 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(); 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 li = langImages(code, join(src, l.id), icon, tablets); 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'}`); 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); 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) // a language with no listing on Play yet answers 404: skipped (play listing creates it), never fatal const plan = new Map(); const unlisted: string[] = []; await play.read(async id => { for (const code of codes) { const li = sets.get(code)!, out: ImageChange[] = []; try { for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(storeSha))); 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; for (const [i, code] of codes.entries()) { 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 change' : 'every image set is already on Play'}; read-only, nothing uploaded`); return; } 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 li = sets.get(code)!; await play.edit(async (id, ctl) => { 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(storeSha)).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 - deleted} set(s)${deleted ? `, ${deleted} set(s) deleted (--prune)` : ''}`); }); } done('play', 'images done'); }