xemu-website/generate.py

378 lines
15 KiB
Python
Raw Normal View History

2021-10-14 02:51:52 +00:00
#!/usr/bin/env python3
2020-04-17 03:35:07 +00:00
"""
xemu.app Static Site Generator
"""
2020-04-03 11:58:48 +00:00
import codecs
import json
import os
import re
2020-04-17 03:35:07 +00:00
import requests
2020-04-05 02:26:14 +00:00
from collections import defaultdict
2020-04-17 03:35:07 +00:00
from datetime import datetime, timezone
2021-05-19 21:18:37 +00:00
from functools import reduce
2020-04-05 02:26:14 +00:00
from github import Github
2020-04-03 11:58:48 +00:00
from jinja2 import Environment, FileSystemLoader
2020-04-05 02:26:14 +00:00
from tqdm import tqdm
2021-06-15 04:56:36 +00:00
from minify_html import minify as minify_html
2020-04-03 11:58:48 +00:00
2023-01-10 22:06:29 +00:00
gh_token = os.getenv('GH_TOKEN')
2020-04-17 03:35:07 +00:00
output_dir = 'dist'
2023-01-19 22:21:28 +00:00
xdb_repo_url_base = 'https://raw.githubusercontent.com/xemu-project/xdb/master/'
2020-04-17 03:35:07 +00:00
compatibility_reports_url = 'https://reports.xemu.app/compatibility'
compatibility_reports_url_verify_certs = True
main_url_base = os.environ.get('BASE_URL', 'https://xemu.app')
2020-04-17 03:35:07 +00:00
# compatibility_reports_url = 'https://127.0.0.1/compatibility'
# compatibility_reports_url_verify_certs = False
2020-04-03 11:58:48 +00:00
develop_mode = os.environ.get('DEV', 0) == '1'
2021-06-09 23:02:07 +00:00
disable_load_issues = develop_mode
disable_load_reports = develop_mode
disable_load_version = develop_mode
2020-04-03 11:58:48 +00:00
title_status_descriptions = {
2020-04-05 02:26:14 +00:00
'Unknown' : 'A compatibility test has not been recorded for this title.',
'Broken' : 'This title crashes very soon after launching, or displays nothing at all.',
'Intro' : 'This title displays an intro sequence, but fails to make it to gameplay.',
'Starts' : 'This title starts, but may crash or have significant issues.',
'Playable' : 'This title is playable, with minor issues.',
2020-04-05 02:26:14 +00:00
'Perfect' : 'This title is playable from start to finish with no noticable issues.'
}
2020-04-03 11:58:48 +00:00
def get_field(s,x):
2020-04-05 02:26:14 +00:00
return s[x] if x in s else ''
class Issue:
2020-04-17 03:35:07 +00:00
issues_by_title = defaultdict(list)
2022-06-27 00:36:21 +00:00
all_issues = []
2020-04-05 02:26:14 +00:00
2022-06-27 00:36:21 +00:00
def __init__(self, number, url, title, affected_titles, created_at, updated_at, closed_at, state):
2020-04-05 02:26:14 +00:00
self.number = number
self.url = url
self.title = title
self.affected_titles = affected_titles
self.created_at = created_at
self.updated_at = updated_at
2022-06-27 00:36:21 +00:00
self.closed_at = closed_at
self.state = state
2020-04-05 02:26:14 +00:00
def __repr__(self):
return self.title
@classmethod
2023-07-28 06:50:28 +00:00
def load_issues(cls, title_alias_map, title_lookup):
2020-04-05 02:26:14 +00:00
"""
2020-04-17 03:35:07 +00:00
Search through all GitHub issues for any title tags to construct a
list of titles and their associated issues
2020-04-05 02:26:14 +00:00
"""
2021-06-09 23:02:07 +00:00
if disable_load_issues:
return
titles_re = re.compile(r'Titles?[:/]\s*([a-fA-f0-9,\s]+)', re.IGNORECASE)
2020-04-17 03:35:07 +00:00
title_id_re = re.compile(r'([a-fA-f0-9]{8})')
2023-01-10 22:06:29 +00:00
for issue in Github(gh_token).get_user('xemu-project').get_repo('xemu').get_issues(state='all'):
2020-04-17 03:35:07 +00:00
# Look for a titles sequence and pull out anything that looks like
# an id
2021-10-14 02:51:52 +00:00
references = ' '.join(titles_re.findall(issue.body or ''))
2023-07-28 06:50:28 +00:00
affected_titles = set()
for title_id in title_id_re.findall(references):
if title_id not in title_alias_map:
print('Warning: Issue %s references unknown title id "%s"' % (issue.url, title_id))
continue
affected_titles.add(title_lookup[title_alias_map[title_id]])
issue = cls(
2020-04-05 02:26:14 +00:00
issue.number,
issue.html_url,
issue.title,
affected_titles,
2022-06-27 00:36:21 +00:00
issue.created_at.replace(tzinfo=timezone.utc),
issue.updated_at.replace(tzinfo=timezone.utc),
issue.closed_at.replace(tzinfo=timezone.utc) if issue.state == 'closed' else None,
2023-07-28 06:50:28 +00:00
issue.state)
2020-04-05 02:26:14 +00:00
2023-07-28 06:50:28 +00:00
cls.all_issues.append(issue)
for title in issue.affected_titles:
if issue not in cls.issues_by_title[title.info['title_id']]:
cls.issues_by_title[title.info['title_id']].append(issue)
@property
def blocked_titles(self):
return {t for t in self.affected_titles if t.status in {'Broken', 'Intro', 'Starts'}}
2020-04-17 03:35:07 +00:00
2022-06-27 00:36:21 +00:00
2020-04-17 03:35:07 +00:00
class CompatibilityReport:
all_reports = []
reports_by_title = defaultdict(list)
def __init__(self, info):
self.info = info
@property
def created_at(self):
return datetime.fromtimestamp(self.info['created_at'], timezone.utc)
@classmethod
def load_reports(cls, title_alias_map, url, verify):
# FIXME: Ideally shouldn't load this all into memory. Instead, save to
# disk and load on demand. But this works for now.
2021-06-09 23:02:07 +00:00
if disable_load_reports:
return
2020-04-17 03:35:07 +00:00
cls.all_reports = [CompatibilityReport(i) for i in json.loads(requests.get(url, verify=verify).text)]
for report in cls.all_reports:
2020-06-07 02:31:49 +00:00
title_id = '%08x' % report.info['xbe_cert_title_id']
2020-04-17 03:35:07 +00:00
if title_id not in title_alias_map:
print('Warning: Compatibility report %s references unknown title "%s"' % (report.info['_id'], title_id))
continue
cls.reports_by_title[title_alias_map[title_id]].append(report)
2020-04-03 11:58:48 +00:00
class Title:
2020-04-05 02:26:14 +00:00
def __init__(self, info_path):
with open(info_path) as f:
self.info = json.load(f)
self.pubid = codecs.decode(self.info['title_id'][0:4], 'hex').decode('ascii')
self.tid = '%03d' % (int(self.info['title_id'][4:], 16))
self.title_name = self.info['name']
2021-05-19 21:18:37 +00:00
anchor_text = ''.join([c if c.isalnum() else '-' for c in self.title_name])
anchor_text = reduce(lambda s, c: s if (s.endswith('-') and c == '-') else s+c, anchor_text)
anchor_text = anchor_text.lstrip('-').rstrip('-')
self.title_url = f"/titles/{self.info['title_id']}#{anchor_text}"
self.title_path = os.path.dirname(info_path)
2023-01-19 22:21:28 +00:00
self.title_xdb_path = self.title_path[4:]
2020-04-05 02:26:14 +00:00
self.full_title_id_text = '%s-%s' % (self.pubid, self.tid)
self.full_title_id_hex = self.info['title_id']
2021-06-14 10:45:39 +00:00
self.full_title_id_num = int(self.info['title_id'], 16)
2020-04-05 02:26:14 +00:00
# Determine cover paths
self.have_cover = True
self.cover_path = f'cover_front.jpg'
if not os.path.exists(os.path.join(self.title_path, self.cover_path)):
# Try .png extension
self.cover_path = f'cover_front.png'
if not os.path.exists(os.path.join(self.title_path, self.cover_path)):
self.have_cover = False
2021-05-19 21:18:37 +00:00
self.cover_back_path = None
self.cover_back_url = None
self.disc_path = None
self.disc_path_url = None
for attr, filename in [
('cover_back_path', 'cover_back.jpg'),
('cover_back_path', 'cover_back.png'),
('disc_path', 'media.jpg'),
('disc_path', 'media.png'),
]:
if os.path.exists(os.path.join(self.title_path, filename)):
setattr(self, attr, filename)
if self.cover_back_path:
2023-01-19 22:21:28 +00:00
self.cover_back_url = xdb_repo_url_base + self.title_xdb_path + '/' + self.cover_back_path
if self.disc_path:
2023-01-19 22:21:28 +00:00
self.disc_url = xdb_repo_url_base + self.title_xdb_path + '/' + self.disc_path
2020-04-05 02:26:14 +00:00
self.have_thumbnail = True
self.cover_thumbnail_path = 'cover_front_thumbnail.jpg'
if not os.path.exists(os.path.join(self.title_path, self.cover_thumbnail_path)):
assert not self.have_cover, "Please create thumbnail for %s" % self.title_name
self.have_thumbnail = False
2020-04-05 02:26:14 +00:00
if self.have_cover:
2023-01-19 22:21:28 +00:00
self.cover_url = xdb_repo_url_base + self.title_xdb_path + '/' + self.cover_path
2020-04-05 02:26:14 +00:00
else:
print('Note: Missing artwork for %s' % self.title_name)
2023-01-19 22:21:28 +00:00
self.cover_url = xdb_repo_url_base + '/resources/cover_front_defxdb_ault.png'
2020-04-05 02:26:14 +00:00
if self.have_thumbnail:
2023-01-19 22:21:28 +00:00
self.cover_thumbnail_url = xdb_repo_url_base + self.title_xdb_path + '/' + self.cover_thumbnail_path
2020-04-05 02:26:14 +00:00
else:
if self.have_cover:
print('Note: Missing thumbnail for %s' % self.title_name)
self.cover_thumbnail_url = self.cover_url
xtimage_path = os.path.join(self.title_path, 'xtimage.png')
if os.path.exists(xtimage_path):
2023-01-19 22:21:28 +00:00
self.xtimage_url = xdb_repo_url_base + self.title_xdb_path + '/xtimage.png'
else:
self.xtimage_url = None
2020-04-17 03:35:07 +00:00
def process_compatibility(self):
self.compatibility_tests = CompatibilityReport.reports_by_title[self.full_title_id_hex]
2020-04-05 02:26:14 +00:00
if len(self.compatibility_tests) > 0:
2020-04-17 03:35:07 +00:00
self.most_recent_test = sorted(self.compatibility_tests, key=lambda x:x.info['created_at'])[-1]
self.status = self.most_recent_test.info['compat_rating']
assert(self.status in title_status_descriptions)
2020-04-05 02:26:14 +00:00
else:
2020-04-17 03:35:07 +00:00
self.most_recent_test = None
2020-04-05 02:26:14 +00:00
self.status = 'Unknown'
assert(self.status in title_status_descriptions)
@property
def issues(self):
2022-06-27 00:36:21 +00:00
"""
Open issues affecting this title.
"""
return [i for i in Issue.issues_by_title[self.info['title_id']]
if i.state == 'open']
@property
def recently_closed_issues(self):
"""
Issues affecting this game that were closed recently (since last report) and may impact playability status.
"""
if self.most_recent_test is None:
return []
return [i for i in Issue.issues_by_title[self.info['title_id']]
if i.state != 'open' and self.most_recent_test.created_at < i.closed_at]
2020-04-03 11:58:48 +00:00
def main():
2020-04-05 02:26:14 +00:00
env = Environment(loader=FileSystemLoader(searchpath='templates'))
game_status_counts = {
'Unknown' : 0,
'Broken' : 0,
'Intro' : 0,
'Starts' : 0,
'Playable' : 0,
'Perfect' : 0,
}
print('Gathering info.json files...')
titles = []
title_alias_map = {}
title_lookup = {}
2023-01-19 22:11:33 +00:00
for root, dirs, files in os.walk('xdb/titles', topdown=True):
2020-04-05 02:26:14 +00:00
for name in files:
if name != 'info.json': continue
title = Title(os.path.join(root,name))
titles.append(title)
2020-04-17 03:35:07 +00:00
assert(title.full_title_id_hex not in title_lookup), "Title %s defined in multiple places" % title.full_title_id_hex
2020-04-05 02:26:14 +00:00
title_lookup[title.full_title_id_hex] = title
for release in title.info['releases']:
title_alias_map[release['title_id']] = title.info['title_id']
print(' - Found %d' % (len(titles)))
print('Getting GitHub Issues List...')
2023-07-28 06:50:28 +00:00
Issue.load_issues(title_alias_map, title_lookup)
2020-04-17 03:35:07 +00:00
print(' - Ok. %d issues total' % len(Issue.all_issues))
print('Getting compatibility report list...')
CompatibilityReport.load_reports(
title_alias_map,
compatibility_reports_url,
compatibility_reports_url_verify_certs
)
print(' - Ok. %d reports total' % len(CompatibilityReport.all_reports))
for title in titles:
title.process_compatibility()
game_status_counts[title.status] += 1
2020-04-05 02:26:14 +00:00
print('Rebuilding pages...')
template = env.get_template('template_title.html')
count = 0
for title_id in tqdm(title_lookup):
title_dir = os.path.join(output_dir, 'titles', title_id)
os.makedirs(title_dir, exist_ok=True)
title = title_lookup[title_id]
with open(os.path.join(title_dir, 'index.html'), 'w') as f:
2021-06-15 04:56:36 +00:00
f.write(minify_html(template.render(
2020-04-05 02:26:14 +00:00
title=title,
2021-06-18 08:11:04 +00:00
title_status_descriptions=title_status_descriptions,
main_url_base=main_url_base
2021-06-15 04:56:36 +00:00
)))
2020-04-05 02:26:14 +00:00
count += 1
print(' - Created %d title pages' % count)
print('Generating alias redirects...')
count = 0
for title_id in title_alias_map:
if title_alias_map[title_id] != title_id:
# This is an alias, create a redirect
title_dir = os.path.join(output_dir, 'titles', title_id)
os.makedirs(title_dir, exist_ok=True)
with open(os.path.join(title_dir, 'index.html'), 'w') as f:
url=f"/titles/{title_alias_map[title_id]}"
f.write(f'<html><head><meta http-equiv="refresh" content="0; URL={url!s}" /></head></html>')
count += 1
print(' - Created %d redirect pages' % count)
2021-06-09 23:02:07 +00:00
if disable_load_version:
xemu_build_tag = 'build-202106041913'
2022-06-27 00:36:21 +00:00
xemu_build_version = '0.7.55'
2021-06-09 23:02:07 +00:00
xemu_build_date = datetime(2021, 6, 4, 19, 13, 6)
else:
xemu_build_version = requests.get('https://raw.githubusercontent.com/xemu-project/xemu/ppa-snapshot/XEMU_VERSION').text
2023-01-10 22:06:29 +00:00
latest_release = Github(gh_token).get_user('xemu-project').get_repo('xemu').get_latest_release()
2021-06-09 23:02:07 +00:00
xemu_build_date = latest_release.created_at
2020-04-05 02:26:14 +00:00
print('Rebuilding index...')
template = env.get_template('template_index.html')
2021-06-14 10:45:39 +00:00
tmap = {t.full_title_id_num : t for t in titles}
from rank import rank
dorder = [tmap.pop(k) for k in rank]
dorder.extend(sorted(tmap.values(),key=lambda title:title.title_name))
2020-04-05 02:26:14 +00:00
with open(os.path.join(output_dir, 'index.html'), 'w') as f:
2021-06-15 04:56:36 +00:00
f.write(minify_html(template.render(
2021-06-14 10:45:39 +00:00
titles=dorder,
2020-04-05 02:26:14 +00:00
title_status_descriptions=title_status_descriptions,
2021-06-09 23:02:07 +00:00
game_status_counts=game_status_counts,
xemu_build_version=xemu_build_version,
2021-06-18 08:11:04 +00:00
xemu_build_date=xemu_build_date,
main_url_base=main_url_base
2021-06-15 04:56:36 +00:00
), minify_js=True, minify_css=True))
2020-04-05 02:26:14 +00:00
print(' - Ok')
2020-04-03 11:58:48 +00:00
2022-06-27 00:36:21 +00:00
print('Building testing priority table')
# Include titles that are either not Playable or have recently closed issues
def filter_(t):
if t.most_recent_test and t.most_recent_test.info['xemu_version'] == xemu_build_version:
return False # Up to date
if len(t.recently_closed_issues) > 0:
return True # Make sure the issues described are fixed
return t.status not in {'Playable', 'Perfect'}
def rank(t):
considered_playable = t.status in {'Playable', 'Perfect'}
have_recently_closed_issues = len(t.recently_closed_issues) > 0
ts = t.most_recent_test.created_at if t.most_recent_test else datetime.fromtimestamp(0, timezone.utc)
return (not have_recently_closed_issues, considered_playable, ts)
template = env.get_template('testing_priority.html')
with open(os.path.join(output_dir, 'testing_priority.html'), 'w') as f:
f.write(
minify_html(
template.render(
titles=sorted([t for t in titles if filter_(t)], key=rank)),
minify_js=True, minify_css=True))
print(' - Ok')
2023-07-28 06:50:28 +00:00
print('Building top issues table')
issues_by_num_affected = [i for i in Issue.all_issues if i.state != 'closed' and i.affected_titles]
issues_by_num_affected.sort(key=lambda i: len(i.affected_titles), reverse=True)
issues_by_num_blocking = [i for i in issues_by_num_affected if len(i.blocked_titles)]
issues_by_num_blocking.sort(key=lambda i: len(i.blocked_titles), reverse=True)
template = env.get_template('top_issues.html')
with open(os.path.join(output_dir, 'top_issues.html'), 'w') as f:
f.write(
minify_html(
template.render(issues_by_num_affected=issues_by_num_affected, issues_by_num_blocking=issues_by_num_blocking),
minify_js=True, minify_css=True))
print(' - Ok')
2023-01-16 23:01:43 +00:00
print('Updating download.md with latest build version')
with open('docs/docs/download.md', 'r', encoding='utf-8') as f:
t = f.read()
t = t.replace(r'{{xemu_version}}', xemu_build_version)
with open('docs/docs/download.md', 'w', encoding='utf-8') as f:
f.write(t)
print(' - Ok')
2020-04-03 11:58:48 +00:00
if __name__ == '__main__':
2020-04-05 02:26:14 +00:00
main()