Skip to content

Commit 70c22ac

Browse files
committed
api: cache previews on s3, and invalidate on dev portal updates
1 parent d4806e2 commit 70c22ac

File tree

6 files changed

+94
-16
lines changed

6 files changed

+94
-16
lines changed

appstore/api.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1+
import io
12
import urllib.parse
23

34
from flask import Blueprint, request, jsonify, abort, url_for, make_response
45
from flask_cors import CORS
56
from sqlalchemy import and_
67

78
from sqlalchemy.orm.exc import NoResultFound
9+
from sqlalchemy.exc import PendingRollbackError
810

911
from appstore.utils import jsonify_app, asset_fallback, generate_image_url, get_access_token, plat_dimensions, HARDWARE_SUPPORT
1012
from .models import App, Collection, HomeBanners, Category, db, Release
1113
from .settings import config
1214
from .image import generate_preview_image
1315

16+
from .s3 import upload_asset, download_asset
17+
1418
parent_app = None
1519
api = Blueprint('api', __name__)
1620
CORS(api)
@@ -71,18 +75,44 @@ def apps_by_id(key):
7175
@api.route('/apps/id/<key>/preview')
7276
def app_image_by_id(key):
7377
app = App.query.filter_by(id=key).one_or_none()
74-
screenshots = {}
75-
for hw in ['aplite', 'basalt', 'chalk', 'diorite', 'emery', 'flint']:
76-
if hw in app.asset_collections:
77-
screenshot = app.asset_collections[hw].screenshots[0]
78-
if screenshot:
79-
screenshots[hw] = screenshot
80-
81-
icon = None
82-
if app.type == 'watchapp':
83-
icon = app.icon_large
84-
png = generate_preview_image(title=app.title, developer=app.developer.name, icon=icon, screenshots=screenshots)
85-
response = make_response(png)
78+
79+
if not app.preview_image:
80+
# Guess we will have to generate one.
81+
screenshots = {}
82+
for hw in ['aplite', 'basalt', 'chalk', 'diorite', 'emery', 'flint']:
83+
if hw in app.asset_collections:
84+
screenshot = app.asset_collections[hw].screenshots[0]
85+
if screenshot:
86+
screenshots[hw] = screenshot
87+
88+
icon = None
89+
if app.type == 'watchapp':
90+
icon = app.icon_large
91+
png = generate_preview_image(title=app.title, developer=app.developer.name, icon=icon, screenshots=screenshots)
92+
93+
buf = io.BytesIO()
94+
buf.write(png)
95+
buf.seek(0)
96+
# HACK: upload_asset puts this in a print, which is only really valid for actual Files...
97+
setattr(buf, 'name', "preview.png")
98+
asset = upload_asset(buf, mime_type = 'image/png', path = config['S3_PREVIEW_PATH'])
99+
100+
app.preview_image = asset
101+
try:
102+
db.session.commit()
103+
except PendingRollbackError:
104+
# if someone else gets to it at the same time, no big deal
105+
pass
106+
107+
response = make_response(png)
108+
response.headers.set('Content-Type', 'image/png')
109+
return response
110+
111+
# looks like there's a cached version -- grab it!
112+
buf = io.BytesIO()
113+
download_asset(app.preview_image, buf, path = config['S3_PREVIEW_PATH'])
114+
buf.seek(0)
115+
response = make_response(buf.read())
86116
response.headers.set('Content-Type', 'image/png')
87117
return response
88118

appstore/developer_portal_api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,9 @@ def new_app_screenshots(app_id, platform):
450450
new_image_id = upload_asset(new_image, new_image.content_type)
451451
screenshots.append(new_image_id)
452452
asset_collection.screenshots = screenshots
453+
454+
# Invalidate the cached preview.
455+
app.preview_image = None
453456
db.session.commit()
454457

455458
return jsonify(success=True, id=new_image_id, platform=platform)
@@ -485,6 +488,10 @@ def delete_screenshot(app_id, platform, screenshot_id):
485488
), 409
486489

487490
asset_collection.screenshots = list(filter(lambda x: x != screenshot_id, asset_collection.screenshots))
491+
492+
# Invalidate the cached preview.
493+
app.preview_image = None
494+
488495
db.session.commit()
489496
return jsonify(success=True, message=f"Deleted screenshot {screenshot_id}", id=screenshot_id, platform=platform)
490497

@@ -551,6 +558,10 @@ def new_app_banner(app_id, platform):
551558
new_image_id = upload_asset(new_image, new_image.content_type)
552559
headers.append(new_image_id)
553560
asset_collection.headers = headers
561+
562+
# Invalidate the cached preview.
563+
app.preview_image = None
564+
554565
db.session.commit()
555566

556567
return jsonify(success=True, id=new_image_id, platform=platform)
@@ -586,6 +597,10 @@ def delete_banner(app_id, platform, banner_id):
586597
), 409
587598

588599
asset_collection.headers = list(filter(lambda x: x != banner_id, asset_collection.headers))
600+
601+
# Invalidate the cached preview.
602+
app.preview_image = None
603+
589604
db.session.commit()
590605
return jsonify(success=True, message=f"Deleted banner {banner_id}", id=banner_id, platform=platform)
591606

@@ -646,6 +661,9 @@ def new_app_icon(app_id, size):
646661
app.icon_large = new_image_id
647662
elif size == "small":
648663
app.icon_small = new_image_id
664+
665+
# Invalidate the cached preview.
666+
app.preview_image = None
649667
db.session.commit()
650668

651669
return jsonify(success=True, id=new_image_id, size=size)

appstore/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class App(db.Model):
7979
timeline_token = db.Column(db.String, index=True)
8080
installs = db.Column(db.Integer, index=True)
8181
discourse_topic_id = db.Column(db.Integer)
82+
preview_image = db.Column(db.String)
8283

8384

8485
category_banner_apps = Table('category_banner_apps', db.Model.metadata,

appstore/s3.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ def download_pbw(id, file):
8282
s3.download_fileobj(config['S3_BUCKET'], filename, file)
8383

8484

85-
def upload_asset(file, mime_type = None):
85+
def upload_asset(file, mime_type = None, path = config['S3_ASSET_PATH']):
8686
id = id_generator.generate()
87-
filename = f"{config['S3_ASSET_PATH']}{id}"
87+
filename = f"{path}{id}"
8888

8989
if isinstance(file, str):
9090
print(f"uploading file {file} to {config['S3_ASSET_BUCKET']}:{filename}")
@@ -110,8 +110,8 @@ def upload_asset(file, mime_type = None):
110110

111111
return id
112112

113-
def download_asset(id, file):
114-
filename = f"{config['S3_ASSET_PATH']}{id}"
113+
def download_asset(id, file, path = config['S3_ASSET_PATH']):
114+
filename = f"{path}{id}"
115115
s3 = _client_for_endpoint(s3_endpoint)
116116
if isinstance(file, str):
117117
s3.download_file(config['S3_ASSET_BUCKET'], filename, file)

appstore/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
'S3_PATH': os.environ.get('S3_PATH', 'pbw/'),
1919
'S3_ASSET_BUCKET': os.environ.get('S3_ASSET_BUCKET', 'rebble-appstore-assets'),
2020
'S3_ASSET_PATH': os.environ.get('S3_ASSET_PATH', ''),
21+
'S3_PREVIEW_PATH': os.environ.get('S3_PREVIEW_PATH', 'preview_images/'),
2122
'S3_ARCHIVE_BUCKET': os.environ.get('S3_ARCHIVE_BUCKET', 'rebble-archive'),
2223
'S3_ARCHIVE_PATH': os.environ.get('S3_ARCHIVE_PATH' , 'appstore/'),
2324
'HONEYCOMB_KEY': os.environ.get('HONEYCOMB_KEY', None),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""app: add preview_image cache
2+
3+
Revision ID: 0c3a1cfa6af5
4+
Revises: 89e32c02be56
5+
Create Date: 2025-12-11 08:34:29.526881
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '0c3a1cfa6af5'
14+
down_revision = '89e32c02be56'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade():
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column('apps', sa.Column('preview_image', sa.String(), nullable=True))
22+
# ### end Alembic commands ###
23+
24+
25+
def downgrade():
26+
# ### commands auto generated by Alembic - please adjust! ###
27+
op.drop_constraint(None, 'user_flags', type_='foreignkey')
28+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)