// 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 //: 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 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 { done, log, step } from '../lib/log'; import { playClient } from './client'; import { readPlayCopy } from './listing'; import { variantRoot } from '../shots/lib'; export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const; /** 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)!)); } export interface ImageChange { type: string; change: 'same' | 'replace'; 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 }; } const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex'); export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const pkg = need(config.play?.packageName, 'play.packageName'); 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 sets = new Map(); 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); } log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`); 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 plan = new Map(); 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))); } plan.set(code, out); } }); const changedTypes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace'); 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'}`); } done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : '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); 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)!; 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++; } })); if (!types) ctl.unchanged(); log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types} set(s)`); }); } done('play', 'images done'); }