#!/usr/bin/env python3
&#34;&#34;&#34;
App Store Connect — stuck screenshot remover

Safely finds screenshots in an App Store version and removes ONLY screenshots
whose assetDeliveryState is not COMPLETE.

Default mode is DRY RUN: nothing is deleted.
Use --delete to actually delete stuck screenshots.

Requirements:
    python3 -m pip install pyjwt cryptography requests

Authentication:
    App Store Connect API Team Key:
      - Issuer ID
      - Key ID
      - .p8 private key

The script intentionally does NOT delete screenshot sets.
&#34;&#34;&#34;

import argparse
import getpass
import json
import os
import sys
import time
from pathlib import Path

import jwt
import requests


BASE_URL = &#34;https://api.appstoreconnect.apple.com/v1&#34;


def make_token(issuer_id: str, key_id: str, private_key_path: str) -&gt; str:
    with open(os.path.expanduser(private_key_path), &#34;r&#34;, encoding=&#34;utf-8&#34;) as f:
        private_key = f.read()

    now = int(time.time())

    payload = {
        &#34;iss&#34;: issuer_id,
        &#34;iat&#34;: now,
        &#34;exp&#34;: now + 1200,
        &#34;aud&#34;: &#34;appstoreconnect-v1&#34;,
    }

    headers = {
        &#34;alg&#34;: &#34;ES256&#34;,
        &#34;kid&#34;: key_id,
        &#34;typ&#34;: &#34;JWT&#34;,
    }

    return jwt.encode(
        payload,
        private_key,
        algorithm=&#34;ES256&#34;,
        headers=headers,
    )


class ASC:
    def __init__(self, token: str):
        self.session = requests.Session()
        self.session.headers.update({
            &#34;Authorization&#34;: f&#34;Bearer {token}&#34;,
            &#34;Content-Type&#34;: &#34;application/json&#34;,
        })

    def get(self, path, params=None):
        r = self.session.get(BASE_URL + path, params=params)
        self._check(r)
        return r.json()

    def delete(self, path):
        r = self.session.delete(BASE_URL + path)
        self._check(r, allow_204=True)
        return r

    @staticmethod
    def _check(response, allow_204=False):
        if allow_204 and response.status_code == 204:
            return

        if response.status_code &gt;= 400:
            print(f&#34;\nApple API error: HTTP {response.status_code}&#34;)
            try:
                print(json.dumps(response.json(), indent=2, ensure_ascii=False))
            except Exception:
                print(response.text)
            sys.exit(1)


def find_app(api, app_name=None, app_id=None):
    if app_id:
        data = api.get(f&#34;/apps/{app_id}&#34;)
        return data[&#34;data&#34;]

    if not app_name:
        raise ValueError(&#34;Provide --app-id or --app-name&#34;)

    data = api.get(
        &#34;/apps&#34;,
        {
            &#34;filter[name]&#34;: app_name,
            &#34;limit&#34;: 50,
        },
    )

    apps = data.get(&#34;data&#34;, [])

    if not apps:
        raise RuntimeError(f&#34;No app found with name: {app_name}&#34;)

    if len(apps) &gt; 1:
        print(&#34;Multiple apps matched:&#34;)
        for i, app in enumerate(apps, 1):
            print(
                f&#34;  {i}. {app[&#39;id&#39;]} | &#34;
                f&#34;{app[&#39;attributes&#39;].get(&#39;name&#39;)} | &#34;
                f&#34;{app[&#39;attributes&#39;].get(&#39;bundleId&#39;)}&#34;
            )
        raise RuntimeError(
            &#34;Multiple apps found. Re-run with --app-id to avoid ambiguity.&#34;
        )

    return apps[0]


def find_version(api, app_id, version):
    data = api.get(
        f&#34;/apps/{app_id}/appStoreVersions&#34;,
        {
            &#34;filter[versionString]&#34;: version,
            &#34;filter[platform]&#34;: &#34;IOS&#34;,
            &#34;limit&#34;: 50,
        },
    )

    versions = data.get(&#34;data&#34;, [])

    if not versions:
        raise RuntimeError(
            f&#34;No iOS version &#39;{version}&#39; found for app {app_id}&#34;
        )

    if len(versions) &gt; 1:
        print(&#34;Multiple matching versions found:&#34;)
        for v in versions:
            print(
                f&#34;  {v[&#39;id&#39;]} | &#34;
                f&#34;{v[&#39;attributes&#39;].get(&#39;versionString&#39;)} | &#34;
                f&#34;{v[&#39;attributes&#39;].get(&#39;appStoreState&#39;)}&#34;
            )
        raise RuntimeError(
            &#34;Multiple versions found. Resolve the ambiguity before continuing.&#34;
        )

    return versions[0]


def find_localization(api, version_id, locale):
    data = api.get(
        f&#34;/appStoreVersions/{version_id}/appStoreVersionLocalizations&#34;,
        {&#34;limit&#34;: 50},
    )

    matches = [
        x for x in data.get(&#34;data&#34;, [])
        if x[&#34;attributes&#34;].get(&#34;locale&#34;) == locale
    ]

    if not matches:
        available = [
            x[&#34;attributes&#34;].get(&#34;locale&#34;)
            for x in data.get(&#34;data&#34;, [])
        ]
        raise RuntimeError(
            f&#34;Locale &#39;{locale}&#39; not found. Available locales: &#34;
            + &#34;, &#34;.join(available)
        )

    if len(matches) &gt; 1:
        raise RuntimeError(f&#34;Multiple localizations found for {locale}&#34;)

    return matches[0]


def find_sets(api, localization_id, display_type=None):
    params = {&#34;limit&#34;: 50}

    if display_type:
        params[&#34;filter[screenshotDisplayType]&#34;] = display_type

    data = api.get(
        f&#34;/appStoreVersionLocalizations/{localization_id}/appScreenshotSets&#34;,
        params,
    )

    return data.get(&#34;data&#34;, [])


def get_screenshots(api, set_id):
    data = api.get(
        f&#34;/appScreenshotSets/{set_id}/appScreenshots&#34;,
        {&#34;limit&#34;: 50},
    )
    return data.get(&#34;data&#34;, [])


def describe_screenshot(screenshot):
    attrs = screenshot.get(&#34;attributes&#34;, {})
    state = (attrs.get(&#34;assetDeliveryState&#34;) or {}).get(&#34;state&#34;)

    return {
        &#34;id&#34;: screenshot[&#34;id&#34;],
        &#34;fileName&#34;: attrs.get(&#34;fileName&#34;),
        &#34;state&#34;: state,
        &#34;fileSize&#34;: attrs.get(&#34;fileSize&#34;),
        &#34;width&#34;: (attrs.get(&#34;imageAsset&#34;) or {}).get(&#34;width&#34;),
        &#34;height&#34;: (attrs.get(&#34;imageAsset&#34;) or {}).get(&#34;height&#34;),
        &#34;sourceFileChecksum&#34;: attrs.get(&#34;sourceFileChecksum&#34;),
        &#34;uploadOperations&#34;: attrs.get(&#34;uploadOperations&#34;),
    }


def main():
    parser = argparse.ArgumentParser(
        description=&#34;Find and optionally remove stuck App Store Connect screenshots.&#34;
    )

    parser.add_argument(&#34;--issuer-id&#34;, required=True)
    parser.add_argument(&#34;--key-id&#34;, required=True)
    parser.add_argument(&#34;--private-key&#34;, required=True)

    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument(&#34;--app-id&#34;)
    group.add_argument(&#34;--app-name&#34;)

    parser.add_argument(&#34;--version&#34;, required=True)
    parser.add_argument(&#34;--locale&#34;, required=True)
    parser.add_argument(
        &#34;--display-type&#34;,
        help=&#34;Optional, e.g. APP_IPHONE_67. If omitted, all screenshot sets are checked.&#34;,
    )

    parser.add_argument(
        &#34;--delete&#34;,
        action=&#34;store_true&#34;,
        help=&#34;Actually delete stuck screenshots. Without this flag the script is read-only.&#34;,
    )

    args = parser.parse_args()

    token = make_token(
        args.issuer_id,
        args.key_id,
        args.private_key,
    )

    api = ASC(token)

    print(&#34;=== App Store Connect stuck screenshot checker ===\n&#34;)

    app = find_app(api, args.app_name, args.app_id)
    print(
        f&#34;App: {app[&#39;attributes&#39;].get(&#39;name&#39;)} &#34;
        f&#34;({app[&#39;id&#39;]})&#34;
    )

    version = find_version(
        api,
        app[&#34;id&#34;],
        args.version,
    )
    print(
        f&#34;Version: {version[&#39;attributes&#39;].get(&#39;versionString&#39;)} &#34;
        f&#34;| state={version[&#39;attributes&#39;].get(&#39;appStoreState&#39;)}&#34;
    )

    localization = find_localization(
        api,
        version[&#34;id&#34;],
        args.locale,
    )
    print(
        f&#34;Locale: {localization[&#39;attributes&#39;].get(&#39;locale&#39;)} &#34;
        f&#34;({localization[&#39;id&#39;]})&#34;
    )

    sets = find_sets(
        api,
        localization[&#34;id&#34;],
        args.display_type,
    )

    if not sets:
        print(&#34;\nNo screenshot sets found.&#34;)
        return

    stuck = []

    print(&#34;\n=== Screenshot sets ===&#34;)

    for screenshot_set in sets:
        set_id = screenshot_set[&#34;id&#34;]
        display_type = screenshot_set[&#34;attributes&#34;].get(
            &#34;screenshotDisplayType&#34;
        )

        screenshots = get_screenshots(api, set_id)

        print(
            f&#34;\n{display_type} | set={set_id} | &#34;
            f&#34;screenshots={len(screenshots)}&#34;
        )

        for screenshot in screenshots:
            info = describe_screenshot(screenshot)

            print(
                f&#34;  {info[&#39;fileName&#39;]} | &#34;
                f&#34;state={info[&#39;state&#39;]} | &#34;
                f&#34;id={info[&#39;id&#39;]} | &#34;
                f&#34;{info[&#39;width&#39;]}x{info[&#39;height&#39;]}&#34;
            )

            if info[&#34;state&#34;] != &#34;COMPLETE&#34;:
                stuck.append(
                    {
                        &#34;set_id&#34;: set_id,
                        &#34;display_type&#34;: display_type,
                        **info,
                    }
                )

    print(&#34;\n=== Result ===&#34;)

    if not stuck:
        print(&#34;No screenshots with a non-COMPLETE delivery state were found.&#34;)
        return

    print(f&#34;Found {len(stuck)} non-COMPLETE screenshot(s):&#34;)

    for item in stuck:
        print(
            f&#34;  - {item[&#39;display_type&#39;]} | &#34;
            f&#34;{item[&#39;fileName&#39;]} | &#34;
            f&#34;state={item[&#39;state&#39;]} | &#34;
            f&#34;id={item[&#39;id&#39;]}&#34;
        )

    if not args.delete:
        print(
            &#34;\nDRY RUN: nothing was deleted.\n&#34;
            &#34;If these are the stuck screenshots you want to remove, &#34;
            &#34;re-run the same command with --delete.&#34;
        )
        return

    print(
        &#34;\nWARNING: The following screenshot resources will be deleted:&#34;
    )
    for item in stuck:
        print(
            f&#34;  {item[&#39;id&#39;]} | &#34;
            f&#34;{item[&#39;display_type&#39;]} | &#34;
            f&#34;{item[&#39;fileName&#39;]} | &#34;
            f&#34;{item[&#39;state&#39;]}&#34;
        )

    answer = input(&#34;\nType DELETE to continue: &#34;).strip()
    if answer != &#34;DELETE&#34;:
        print(&#34;Aborted. Nothing was deleted.&#34;)
        return

    for item in stuck:
        print(f&#34;\nDeleting {item[&#39;id&#39;]} ...&#34;)
        response = api.delete(
            f&#34;/appScreenshots/{item[&#39;id&#39;]}&#34;
        )

        if response.status_code == 204:
            print(&#34;  Deleted successfully.&#34;)
        else:
            print(
                f&#34;  Unexpected status: {response.status_code}&#34;
            )

    print(&#34;\nDone.&#34;)
    print(
        &#34;Refresh App Store Connect and check the screenshot set again.&#34;
    )


if __name__ == &#34;__main__&#34;:
    main()
