|
1 | 1 | import datetime |
| 2 | +import functools |
2 | 3 | import hashlib |
| 4 | +import io |
3 | 5 | import json |
| 6 | +from queue import * |
| 7 | +from threading import Thread, Lock |
| 8 | +import tempfile |
4 | 9 | import yaml |
5 | 10 |
|
6 | 11 | import flask.json |
|
19 | 24 | from algoliasearch import algoliasearch |
20 | 25 |
|
21 | 26 | from .utils import id_generator, algolia_app |
22 | | -from .models import Category, db, App, Developer, Release, CompanionApp, Binary, AssetCollection, LockerEntry, UserLike |
| 27 | +from .models import Category, db, App, Developer, Release, CompanionApp, Binary, AssetCollection, LockerEntry, UserLike, Collection, AvailableArchive |
23 | 28 | from .pbw import PBW, release_from_pbw |
24 | | -from .s3 import upload_pbw, upload_asset |
| 29 | +from .s3 import upload_pbw, upload_asset, download_pbw, download_asset, upload_archive |
25 | 30 | from .settings import config |
26 | 31 |
|
27 | 32 | if config['ALGOLIA_ADMIN_API_KEY']: |
@@ -578,6 +583,191 @@ def path(base): |
578 | 583 | else: |
579 | 584 | algolia_index.delete_objects([app_obj.id]) |
580 | 585 |
|
| 586 | +def export_archive_to_zip(fn, test_only=False, n_threads=20): |
| 587 | + # test-only: only output a few files, so you can run this without a fast |
| 588 | + # connection to gcs |
| 589 | + assets = set() |
| 590 | + binaries = set() |
| 591 | + |
| 592 | + def _mk_release(rel): |
| 593 | + binaries.add(rel.id) |
| 594 | + |
| 595 | + return { |
| 596 | + 'has_pbw': rel.has_pbw, |
| 597 | + 'binaries': { plat: { |
| 598 | + 'sdk_major': b.sdk_major, |
| 599 | + 'sdk_minor': b.sdk_minor, |
| 600 | + 'process_info_flags': b.process_info_flags, |
| 601 | + 'icon_resource_id': b.icon_resource_id, |
| 602 | + } for plat,b in rel.binaries.items() }, |
| 603 | + 'capabilities': rel.capabilities, |
| 604 | + 'js_md5': rel.js_md5, |
| 605 | + 'published_date': rel.published_date.timestamp() if rel.published_date else None, |
| 606 | + 'release_notes': rel.release_notes, |
| 607 | + 'version': rel.version, |
| 608 | + 'compatibility': rel.compatibility, |
| 609 | + } |
| 610 | + |
| 611 | + def _mk_asset_collection(ass): |
| 612 | + for img in ass.headers: |
| 613 | + assets.add(img) |
| 614 | + assets.add(ass.banner) |
| 615 | + |
| 616 | + return { |
| 617 | + 'description': ass.description, |
| 618 | + 'screenshots': ass.screenshots, |
| 619 | + 'headers': ass.headers, |
| 620 | + 'banner': ass.banner, |
| 621 | + } |
| 622 | + |
| 623 | + def _mk_app(app): |
| 624 | + if _mk_app.n % 1000 == 0: |
| 625 | + print(f"... {_mk_app.n} / {_mk_app.ntotal} ...") |
| 626 | + _mk_app.n += 1 |
| 627 | + assets.add(app.icon_large) |
| 628 | + assets.add(app.icon_small) |
| 629 | + return { |
| 630 | + 'uuid': app.app_uuid, |
| 631 | + 'asset_collections': { plat: _mk_asset_collection(ass) for plat,ass in app.asset_collections.items() }, |
| 632 | + 'category_id': app.category_id, |
| 633 | + 'companion_apps': { plat: |
| 634 | + { |
| 635 | + 'icon': c.icon, |
| 636 | + 'url': c.url, |
| 637 | + 'name': c.name, |
| 638 | + 'pebblekit3': c.pebblekit3, |
| 639 | + } for plat,c in app.companions.items() }, |
| 640 | + 'collection_ids': [ c.id for c in app.collections ], |
| 641 | + 'created_at': app.created_at.timestamp() if app.created_at else None, |
| 642 | + 'developer_id': app.developer_id, |
| 643 | + 'hearts': app.hearts, |
| 644 | + 'releases': { rel.id: _mk_release(rel) for rel in app.releases if rel.is_published }, |
| 645 | + 'icon_large': app.icon_large, |
| 646 | + 'icon_small': app.icon_small, |
| 647 | + 'published_date': app.published_date.timestamp() if app.published_date else None, |
| 648 | + 'source': app.source, |
| 649 | + 'title': app.title, |
| 650 | + 'timeline_enabled': app.timeline_enabled, |
| 651 | + 'type': app.type, |
| 652 | + 'website': app.website, |
| 653 | + } |
| 654 | + _mk_app.n = 0 |
| 655 | + |
| 656 | + with zipfile.ZipFile(fn, mode='w', compression=zipfile.ZIP_DEFLATED) as zf: |
| 657 | + with zf.open("metadata/apps.json", 'w') as appsf: |
| 658 | + print(f"Querying apps...") |
| 659 | + _mk_app.ntotal = App.query.filter(App.visible == True).count() |
| 660 | + apps = { app.id: _mk_app(app) for app in App.query.filter(App.visible == True).yield_per(1000) if app.visible } |
| 661 | + print(f"Exporting apps.json...") |
| 662 | + json.dump(apps, io.TextIOWrapper(appsf)) |
| 663 | + |
| 664 | + with zf.open("metadata/categories.json", 'w') as categoriesf: |
| 665 | + print(f"Exporting categories.json...") |
| 666 | + categories = { |
| 667 | + c.id: { |
| 668 | + 'name': c.name, |
| 669 | + 'slug': c.slug, |
| 670 | + 'colour': c.colour, |
| 671 | + 'icon': c.icon, |
| 672 | + 'app_type': c.app_type, |
| 673 | + 'banner_apps': [ app.id for app in c.banner_apps], |
| 674 | + } for c in Category.query.filter(Category.is_visible == True) |
| 675 | + } |
| 676 | + json.dump(categories, io.TextIOWrapper(categoriesf)) |
| 677 | + |
| 678 | + with zf.open("metadata/collections.json", 'w') as collectionsf: |
| 679 | + print(f"Exporting collections.json...") |
| 680 | + collections = { |
| 681 | + c.id: { |
| 682 | + 'name': c.name, |
| 683 | + 'slug': c.slug, |
| 684 | + 'app_type': c.app_type, |
| 685 | + 'platforms': c.platforms, |
| 686 | + } for c in Collection.query |
| 687 | + } |
| 688 | + json.dump(collections, io.TextIOWrapper(collectionsf)) |
| 689 | + |
| 690 | + with zf.open("metadata/developers.json", 'w') as developersf: |
| 691 | + print(f"Exporting developers.json...") |
| 692 | + developers = { |
| 693 | + d.id: d.name |
| 694 | + for d in Developer.query |
| 695 | + } |
| 696 | + |
| 697 | + # XXX: at some point it might be nice to also provide a Rebble |
| 698 | + # user ID for a developer? though I guess also developers who |
| 699 | + # want to provide this to a user of the archive to verify |
| 700 | + # themselves could just as well give a user oauth creds to |
| 701 | + # verify their developer_id |
| 702 | + json.dump(developers, io.TextIOWrapper(developersf)) |
| 703 | + |
| 704 | + zip_targets = Queue() |
| 705 | + downloads_failed = {} |
| 706 | + |
| 707 | + for ass in assets: |
| 708 | + if ass == "" or ass is None: |
| 709 | + continue |
| 710 | + zip_targets.put((f"assets/{ass[0]}/{ass[1]}/{ass}", functools.partial(download_asset, ass))) |
| 711 | + |
| 712 | + for pbw in binaries: |
| 713 | + if pbw == "" or pbw is None: |
| 714 | + continue |
| 715 | + zip_targets.put((f"binaries/{pbw[0]}/{pbw[1]}/{pbw}.pbw", functools.partial(download_pbw, pbw))) |
| 716 | + |
| 717 | + n = 0 |
| 718 | + zip_lock = Lock() |
| 719 | + def download_thread(): |
| 720 | + nonlocal n, downloads_failed, zip_targets |
| 721 | + while True: |
| 722 | + try: |
| 723 | + fname, download = zip_targets.get_nowait() |
| 724 | + except Empty: |
| 725 | + return |
| 726 | + |
| 727 | + try: |
| 728 | + if test_only and n > 50: |
| 729 | + raise TimeoutError() |
| 730 | + buf = io.BytesIO() |
| 731 | + download(buf) |
| 732 | + with zip_lock, zf.open(fname, 'w') as zff: |
| 733 | + zff.write(buf.getbuffer()) |
| 734 | + buf.close() |
| 735 | + except Exception as e: |
| 736 | + downloads_failed[fname] = repr(e) |
| 737 | + |
| 738 | + if (n % 100) == 0: |
| 739 | + print(f"... {n} done, {zip_targets.qsize()} to go ...") |
| 740 | + n += 1 |
| 741 | + |
| 742 | + zip_targets.task_done() |
| 743 | + |
| 744 | + for i in range(n_threads): |
| 745 | + Thread(target=download_thread).start() |
| 746 | + zip_targets.join() |
| 747 | + |
| 748 | + with zf.open("metadata/failed_downloads.json", "w") as failedf: |
| 749 | + json.dump(downloads_failed, io.TextIOWrapper(failedf)) |
| 750 | + |
| 751 | + with open(f"{os.path.dirname(__file__)}/ARCHIVE_LICENSE", "rb") as rf, zf.open("LICENSE.txt", "w") as wf: |
| 752 | + wf.write(rf.read()) |
| 753 | + |
| 754 | +@apps.command('export-archive') |
| 755 | +@click.option('--upload', is_flag=True) |
| 756 | +@click.option('--output') |
| 757 | +@click.option('--test', is_flag=True) # only dump 100 binaries of each type |
| 758 | +def export_archive(output, upload, test): |
| 759 | + print(f"Preparing to export archive...") |
| 760 | + if not output: |
| 761 | + output = tempfile.TemporaryFile() |
| 762 | + export_archive_to_zip(output, test_only=test) |
| 763 | + if upload: |
| 764 | + now = datetime.datetime.now() |
| 765 | + filename = f"appstore-archive-{now.year:04d}{now.month:02d}.zip" |
| 766 | + print(f"uploading to {filename}") |
| 767 | + upload_archive(filename, output) |
| 768 | + db.session.add(AvailableArchive(filename=filename, created_at=datetime.datetime.now())) |
| 769 | + db.session.commit() |
| 770 | + |
581 | 771 |
|
582 | 772 | def init_app(app): |
583 | 773 | app.cli.add_command(apps) |
0 commit comments