nnnotes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. nnnotes/__init__.py +7 -0
  2. nnnotes/__main__.py +4 -0
  3. nnnotes/acb.py +190 -0
  4. nnnotes/addressables.py +413 -0
  5. nnnotes/adv.py +243 -0
  6. nnnotes/advcommand.py +29 -0
  7. nnnotes/advmedia.py +178 -0
  8. nnnotes/advscene.py +113 -0
  9. nnnotes/advui.py +1154 -0
  10. nnnotes/advvideo.py +684 -0
  11. nnnotes/atoms/__init__.py +151 -0
  12. nnnotes/atoms/astc.py +52 -0
  13. nnnotes/atoms/gltf.py +290 -0
  14. nnnotes/atoms/mesh.py +289 -0
  15. nnnotes/atoms/png.py +28 -0
  16. nnnotes/atoms/reader.py +269 -0
  17. nnnotes/atoms/sniff.py +198 -0
  18. nnnotes/atoms/sprite.py +236 -0
  19. nnnotes/atoms/texture.py +80 -0
  20. nnnotes/cache.py +289 -0
  21. nnnotes/catalog.py +373 -0
  22. nnnotes/catalogdb.py +457 -0
  23. nnnotes/census.py +276 -0
  24. nnnotes/cli.py +744 -0
  25. nnnotes/cli_assets.py +1539 -0
  26. nnnotes/config.py +238 -0
  27. nnnotes/contract.py +497 -0
  28. nnnotes/cri.py +371 -0
  29. nnnotes/crikey.py +71 -0
  30. nnnotes/crilips.py +265 -0
  31. nnnotes/cristages.py +558 -0
  32. nnnotes/deckdata.py +462 -0
  33. nnnotes/export.py +1462 -0
  34. nnnotes/gameapi.py +318 -0
  35. nnnotes/jsonio.py +98 -0
  36. nnnotes/languages.py +44 -0
  37. nnnotes/layout.py +596 -0
  38. nnnotes/link.py +418 -0
  39. nnnotes/live.py +115 -0
  40. nnnotes/live2d.py +235 -0
  41. nnnotes/liveaudio.py +331 -0
  42. nnnotes/livenotes.py +291 -0
  43. nnnotes/liveoptions.py +250 -0
  44. nnnotes/livescene.py +450 -0
  45. nnnotes/liveui.py +328 -0
  46. nnnotes/master.py +251 -0
  47. nnnotes/modelstages.py +424 -0
  48. nnnotes/motion.py +131 -0
  49. nnnotes/objexport.py +1564 -0
  50. nnnotes/orchestrate.py +630 -0
  51. nnnotes/plan.py +218 -0
  52. nnnotes/player.py +392 -0
  53. nnnotes/room.py +391 -0
  54. nnnotes/score.py +1139 -0
  55. nnnotes/shader.py +217 -0
  56. nnnotes/spot.py +201 -0
  57. nnnotes/stages.py +285 -0
  58. nnnotes/store.py +518 -0
  59. nnnotes/story.py +142 -0
  60. nnnotes/storyfonts.py +1258 -0
  61. nnnotes/storyhost.py +1190 -0
  62. nnnotes/storysite.py +662 -0
  63. nnnotes/storyvoices.py +102 -0
  64. nnnotes/textstyle.py +355 -0
  65. nnnotes/tmpfont.py +636 -0
  66. nnnotes/typetrees/6000.3.12f1.json +19 -0
  67. nnnotes/unity.py +368 -0
  68. nnnotes/viewrules.json +441 -0
  69. nnnotes/views.py +966 -0
  70. nnnotes/voices.py +1363 -0
  71. nnnotes/web.py +1624 -0
  72. nnnotes/webaudio.py +14 -0
  73. nnnotes/webmodel.py +397 -0
  74. nnnotes-0.1.0.dist-info/METADATA +200 -0
  75. nnnotes-0.1.0.dist-info/RECORD +79 -0
  76. nnnotes-0.1.0.dist-info/WHEEL +5 -0
  77. nnnotes-0.1.0.dist-info/entry_points.txt +2 -0
  78. nnnotes-0.1.0.dist-info/licenses/LICENSE +21 -0
  79. nnnotes-0.1.0.dist-info/top_level.txt +1 -0
nnnotes/deckdata.py ADDED
@@ -0,0 +1,462 @@
1
+ """Deck data: one JSON file with every live chart as the client builds it at runtime and the master data tables that
2
+ deck-building tools read, for one master data version (format `nnnotes.deck-data/1`, docs/deck-data.md).
3
+
4
+ Master data is decoded from the files as served: a directory with `MasterManifest.json` and the `.bin` files it
5
+ lists (`nnnotes master download`), or the same layout inside the APK (`assets/Master/`). Each file is checked against
6
+ the manifest's SHA-256 and decoded with master.decode. Charts are the TextAssets `Live/MusicScore/<file name>` of
7
+ every MasterLiveMusicScore row, read from the catalog and converted by score.runtime_score; notes are listed in the
8
+ order the client enumerates them.
9
+
10
+ The output is canonical: minified UTF-8 with one trailing LF, keys in a fixed order, charts sorted by score id,
11
+ numbers the master data writes with a fraction or exponent as the shortest decimal that reads back as the same
12
+ binary32 value (infinity as `1e999` / `-1e999`; a NaN is an error). The same inputs give the same bytes; a `.gz`
13
+ output is gzip with no file name and a zero modification time. The file is written only when every table and chart
14
+ was read; any missing or unreadable input is a DeckDataError naming it.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import gzip
19
+ import hashlib
20
+ import json
21
+ import zipfile
22
+ from collections import Counter
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Callable
26
+
27
+ FORMAT = "nnnotes.deck-data/1"
28
+ CHART_FORMAT = "nnnotes.live-score/1" # score.convert's format: the converter the notes come from
29
+ CHART_PREFIX = "Live/MusicScore/" # + MasterLiveMusicScore._musicScoreTextFileName
30
+ MANIFEST = "MasterManifest.json"
31
+ APK_MASTER = "assets/Master/"
32
+ EMBEDDED = "embedded" # provenance region and master source of the APK's master data
33
+ API = "api"
34
+
35
+ _SKILL_EFFECT = ("_level _skillTriggerConditionGroup _skillTriggerType _skillConditionGroup "
36
+ "_skillReleaseConditionGroup _skillTargetIDs _skillEffectType _activationTimeSecond _effectValue "
37
+ "_maxEffectValue _effectLimitCount _skillCumulativeConditionID _effectExecuteLimitCount "
38
+ "_effectExecuteLimitResetConditionGroup")
39
+ _WHOLE = None # every column of the table, in the order the rows first have them
40
+
41
+ # (table, columns), in file order. A table with a column list exports those columns; a whole table all of its own.
42
+ TABLES: tuple[tuple[str, tuple[str, ...] | None], ...] = tuple(
43
+ (t, None if c is _WHOLE else tuple(c.split())) for t, c in (
44
+ ("MasterMemberCard", "_id _characterID _rarity _cardType _bestMusicTagIDs _performancePowerMax _technicPowerMax "
45
+ "_visualPowerMax _memberCardLevelGroup _memberCardAwakeGroup _memberCardRankGroup "
46
+ "_leaderSkillID _liveSkillID _gekisouSkillID"),
47
+ ("MasterMemberCardLevel", "_id _group _level _exp _performanceRate _technicRate _visualRate"),
48
+ ("MasterMemberCardLevelLimit", "_id _rarity _awakeCount _limitLevel"),
49
+ ("MasterMemberCardAwake", "_id _group _awakeCount _performanceRate _technicRate _visualRate"),
50
+ ("MasterMemberCardRank", "_id _group _rank _performanceRate _technicRate _visualRate _leaderSkillLevel "
51
+ "_musicTypeBonusRate _musicTagBonusRate"),
52
+ ("MasterSupportCard", "_id _characterIDs _rarity _cardType _performancePowerMax _technicPowerMax "
53
+ "_visualPowerMax _supportCardLevelGroup _supportCardRankGroup _supportSkillId01 "
54
+ "_supportSkillId02 _gekisouSupportSkillId01 _gekisouSupportSkillId02"),
55
+ ("MasterSupportCardLevel", "_id _group _level _exp _performanceRate _technicRate _visualRate"),
56
+ ("MasterSupportCardRank", "_id _group _rank _limitLevel _cardTypeLinkBonusRate _supportSkill01Level "
57
+ "_supportSkill02Level _gekisouSupportSkill01Level _gekisouSupportSkill02Level"),
58
+ ("MasterCharacter", "_id _bandID"),
59
+ ("MasterBand", "_id"),
60
+ ("MasterCharacterRank", "_id _rank _bonus"),
61
+ ("MasterCharacterTotalRank", "_id _totalRank _bonus"),
62
+ ("MasterBandItemSkillEffect", "_id _bandItemId _level _skillTargetIDs _skillEffectType _effectValue"),
63
+ ("MasterBandItem", "_id _bandId"),
64
+ ("MasterBandItemLevel", "_id _bandItemId _level _playerRank"),
65
+ ("MasterVipRankBonus", "_id _vipRank _vipBonusType _value"),
66
+ ("MasterMemoryMusicGroup", "_id _skillTargetIds"),
67
+ ("MasterMemoryMusic", "_id _groupId"),
68
+ ("MasterMemoryMusicBonus", "_id _groupId _scoreRank _performance _technic _visual"),
69
+ ("MasterMemoryMemberLevel", "_id _point _performance _technic _visual"),
70
+ ("MasterMemorySupportLevel", "_id _point _performance _technic _visual"),
71
+ ("MasterSkillTarget", "_id _skillTargetType _characterID _bandID _cardType _tagID _judgement _liveMusicType "
72
+ "_gekisouMissionType _liveSkillCategories _gekisouSkillCategories"),
73
+ ("MasterSkillCondition", "_id _conditionType _conditionValues _isPositive _conditionTargetIDs"),
74
+ ("MasterSkillConditionSet", "_id _group _conditionIds"),
75
+ ("MasterSkillCumulativeCondition", "_id _skillCumulativeConditionType _conditionValues _conditionTargetIDs "
76
+ "_maxCumulativeCount"),
77
+ ("MasterSkillEffectSetting", "_id _skillEffectType _phase"),
78
+ ("MasterLeaderSkillEffect", "_id _leaderSkillID _level _skillConditionGroup _skillTargetIDs _skillEffectType "
79
+ "_effectValue _skillCumulativeConditionID"),
80
+ ("MasterLiveSkill", "_id _skillCategories"),
81
+ ("MasterLiveSkillEffect", "_id _liveSkillID _level _skillConditionGroup _skillReleaseConditionGroup "
82
+ "_skillTargetIDs _skillEffectType _activationTimeSecond _effectValue _maxEffectValue "
83
+ "_effectLimitCount _skillCumulativeConditionID _effectExecuteLimitCount "
84
+ "_effectExecuteLimitResetConditionGroup"),
85
+ ("MasterSupportSkill", "_id"),
86
+ ("MasterSupportSkillEffect", "_id _supportSkillID " + _SKILL_EFFECT),
87
+ ("MasterGekisouSkill", "_id _gekisouMissionType _skillCategories"),
88
+ ("MasterGekisouSkillEffect", "_id _gekisouSkillID " + _SKILL_EFFECT),
89
+ ("MasterGekisouSupportSkill", "_id _gekisouSupportSkillExecTiming _gekisouMissionType"),
90
+ ("MasterGekisouSupportSkillEffect", "_id _gekisouSupportSkillID " + _SKILL_EFFECT),
91
+ ("MasterLiveNoteParameter", "_id _noteOperateType _scorePercent"),
92
+ ("MasterLiveJudgementParameter", "_id _noteSimulateJudgement _scorePercent _damage"),
93
+ ("MasterLiveJudgementTiming", "_id _assistLevel _judgementPriority _noteJudgementType _noteSimulateJudgement "
94
+ "_beforeMs _afterMs"),
95
+ ("MasterLiveComboScoreBonus", "_id _comboBonusType _requiredComboCount _bonusFactor"),
96
+ ("MasterLiveSettings", _WHOLE),
97
+ ("MasterParameter", _WHOLE),
98
+ ("MasterLiveGekisouLuckBasePoint", "_id _noteCategory _noteSimulateJudgement _weight _basePoint"),
99
+ ("MasterLiveGekisouLuckBonusLot", "_id _chanceLotType _lotResult _weight"),
100
+ ("MasterLiveGekisouRankingScoreBonus", "_id _missionPattern _rank _count _scoreBonusPercent"),
101
+ ("MasterLiveMusic", "_id _musicType _bestMusicTagIDs _liveScoreRankGroup _easyID _normalID _hardID _expertID "
102
+ "_gekisouMission1 _gekisouMission2 _gekisouMission3"),
103
+ ("MasterLiveMusicScore", "_id _musicScoreTextFileName _musicScoreLevel _fullComboCount"),
104
+ ("MasterArenaMusic", _WHOLE),
105
+ ("MasterChallengeMusic", _WHOLE),
106
+ ("MasterLiveScoreRank", "_id _group _liveScoreRank _requiredScore _battleLiveRequiredScore"),
107
+ ("MasterEvent", _WHOLE),
108
+ ("MasterEventEffect", _WHOLE),
109
+ ("MasterLiveEventPoint", _WHOLE),
110
+ ("MasterChallengeLiveEventPoint", _WHOLE),
111
+ ("MasterLiveChallengePoint", _WHOLE),
112
+ ("MasterLiveMusicBoostBonus", "_id _consumedLiveBoostCount _liveMusicRewardRate _playerExpRate "
113
+ "_memberCardExpRate _friendshipExpRate _eventPointRate"),
114
+ ("MasterChallengeMusicBoostBonus", _WHOLE),
115
+ ))
116
+
117
+
118
+ class DeckDataError(ValueError):
119
+ """An input the deck data cannot be made from (the message names it)."""
120
+
121
+
122
+ # ---------------------------------------------------------------- master data as served
123
+ @dataclass(frozen=True)
124
+ class MasterSource:
125
+ """Master data files as served: `version` and the SHA-256 of each file name from the manifest, and a reader of
126
+ files by name."""
127
+ source: str # API or EMBEDDED
128
+ where: str # the directory or APK, for messages
129
+ version: str | None
130
+ hashes: dict[str, str] # file name -> sha256 (lowercase hex; "" when not listed)
131
+ read: Callable[[list[str]], dict[str, bytes]] # file names -> {name: bytes}; a missing file raises KeyError
132
+
133
+
134
+ def _manifest(raw: bytes, where: str) -> tuple[str | None, dict[str, str]]:
135
+ try:
136
+ doc = json.loads(raw.decode("utf-8"))
137
+ files = doc["files"]
138
+ hashes = {f["name"]: str(f.get("hash") or "").lower() for f in files}
139
+ except (ValueError, KeyError, TypeError, AttributeError):
140
+ raise DeckDataError(f"{where}: {MANIFEST} is not a master data manifest") from None
141
+ version = doc.get("version")
142
+ return (str(version) if version is not None else None), hashes
143
+
144
+
145
+ def master_files(directory) -> MasterSource:
146
+ """The master data files of a directory written by `nnnotes master download` (MasterManifest.json + .bin)."""
147
+ d = Path(directory)
148
+ m = d / MANIFEST
149
+ if not m.is_file():
150
+ raise DeckDataError(f"{d}: no {MANIFEST} (a directory written by `nnnotes master download`)")
151
+ version, hashes = _manifest(m.read_bytes(), str(d))
152
+
153
+ def read(names):
154
+ out = {}
155
+ for n in names:
156
+ try:
157
+ out[n] = (d / n).read_bytes()
158
+ except FileNotFoundError:
159
+ raise KeyError(n) from None
160
+ return out
161
+ return MasterSource(API, str(d), version, hashes, read)
162
+
163
+
164
+ def apk_master(apk) -> MasterSource:
165
+ """The master data files the APK ships (`assets/Master/`: MasterManifest.json + .bin)."""
166
+ apk = Path(apk)
167
+ try:
168
+ with zipfile.ZipFile(apk) as z:
169
+ raw = z.read(APK_MASTER + MANIFEST)
170
+ except KeyError:
171
+ raise DeckDataError(f"{apk}: no {APK_MASTER}{MANIFEST}") from None
172
+ except (zipfile.BadZipFile, OSError):
173
+ raise DeckDataError(f"{apk}: not a readable APK") from None
174
+ version, hashes = _manifest(raw, f"{apk} {APK_MASTER}")
175
+
176
+ def read(names):
177
+ with zipfile.ZipFile(apk) as z:
178
+ have = set(z.namelist())
179
+ missing = [n for n in names if APK_MASTER + n not in have]
180
+ if missing:
181
+ raise KeyError(missing[0])
182
+ return {n: z.read(APK_MASTER + n) for n in names}
183
+ return MasterSource(EMBEDDED, f"{apk} {APK_MASTER}", version, hashes, read)
184
+
185
+
186
+ def read_master(src: MasterSource, key) -> tuple[dict[str, list[dict]], dict[str, str]]:
187
+ """The rows (`_allData`) of every table of TABLES and the SHA-256 of each file as served. `key`: a
188
+ master.MasterKey."""
189
+ from . import master
190
+ names = {t: f"{t}.bin" for t, _ in TABLES}
191
+ unlisted = [t for t, n in names.items() if n not in src.hashes]
192
+ if unlisted:
193
+ raise DeckDataError(f"master data {src.where}: {MANIFEST} lists no {', '.join(unlisted)}")
194
+ try:
195
+ data = src.read(list(names.values()))
196
+ except KeyError as e:
197
+ raise DeckDataError(f"master data {src.where}: no file {e.args[0]}") from None
198
+ rk = master.round_keys(key.key)
199
+ tables, shas = {}, {}
200
+ for t, n in names.items():
201
+ raw = data[n]
202
+ sha = hashlib.sha256(raw).hexdigest()
203
+ if src.hashes[n] and sha != src.hashes[n]:
204
+ raise DeckDataError(f"master data {src.where}: {n}: sha256 differs from the manifest")
205
+ try:
206
+ doc = json.loads(master.decode(raw, key, rk).decode("utf-8"))
207
+ except Exception as e: # padding, gzip, UTF-8 or JSON: the file cannot be read
208
+ raise DeckDataError(f"master data {src.where}: {n} cannot be decoded ({type(e).__name__}: "
209
+ f"{str(e)[:120]})") from None
210
+ rows = doc.get("_allData") if isinstance(doc, dict) else None
211
+ if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
212
+ raise DeckDataError(f"master data {src.where}: {n} has no `_allData` rows")
213
+ tables[t], shas[t] = rows, sha
214
+ return tables, shas
215
+
216
+
217
+ # ---------------------------------------------------------------- canonical values
218
+ class _Num(str):
219
+ """A number token, written as it is."""
220
+
221
+
222
+ def f32_token(v: float) -> _Num:
223
+ """The shortest decimal that reads back as binary32(v); +/-infinity as 1e999 / -1e999; NaN raises ValueError."""
224
+ import numpy as np
225
+ with np.errstate(over="ignore"):
226
+ x = np.float32(v)
227
+ if np.isnan(x):
228
+ raise ValueError("NaN (no JSON representation)")
229
+ if np.isinf(x):
230
+ return _Num("1e999" if x > 0 else "-1e999")
231
+ if x == 0 or 1e-4 <= abs(x) < 1e16:
232
+ return _Num(np.format_float_positional(x, unique=True, trim="0"))
233
+ return _Num(np.format_float_scientific(x, unique=True, trim="0"))
234
+
235
+
236
+ def _value(v, where: str):
237
+ if isinstance(v, float):
238
+ try:
239
+ return f32_token(v)
240
+ except ValueError as e:
241
+ raise DeckDataError(f"{where}: {e}") from None
242
+ if isinstance(v, list):
243
+ return [_value(x, where) for x in v]
244
+ if isinstance(v, dict):
245
+ return {k: _value(x, where) for k, x in v.items()}
246
+ return v
247
+
248
+
249
+ def master_subset(tables: dict[str, list[dict]]) -> dict:
250
+ """{table: {"columns", "rows"}} for TABLES, rows in the served order. A table with a column list must have each
251
+ of them in every row; a whole table exports the columns its rows have (none when it has no rows)."""
252
+ out = {}
253
+ for name, cols in TABLES:
254
+ rows = tables[name]
255
+ if cols is None:
256
+ seen: dict[str, None] = {}
257
+ for r in rows:
258
+ seen.update(dict.fromkeys(r))
259
+ cols = tuple(seen)
260
+ for i, r in enumerate(rows):
261
+ missing = [c for c in cols if c not in r]
262
+ if missing:
263
+ raise DeckDataError(f"{name}: row {i} (_id {r.get('_id')}) has no column {missing[0]}")
264
+ out[name] = {"columns": list(cols),
265
+ "rows": [[_value(r[c], f"{name} row {i} {c}") for c in cols] for i, r in enumerate(rows)]}
266
+ return out
267
+
268
+
269
+ # ---------------------------------------------------------------- charts
270
+ def chart_key(file_name: str) -> str:
271
+ return CHART_PREFIX + file_name
272
+
273
+
274
+ def chart_record(score_id: int, key: str, raw: bytes) -> dict:
275
+ """The record of one chart: the TextAsset bytes as shipped -> runtime notes in enumeration order, skill events
276
+ in chart order, fever ranges sorted by start."""
277
+ from . import score
278
+ try:
279
+ root = score.load_bytes(raw)
280
+ except (ValueError, OSError, EOFError):
281
+ raise DeckDataError(f"chart {key}: not a chart (gzip or JSON cannot be read)") from None
282
+ try:
283
+ rs = score.runtime_score(root)
284
+ except Exception as e: # the converter's own errors and malformed chart fields
285
+ raise DeckDataError(f"chart {key}: cannot be converted ({type(e).__name__}: {e})") from None
286
+ ids, ops, judgements, times = [], [], [], []
287
+ seen = set()
288
+ for n in rs.notes:
289
+ if n.id in seen:
290
+ raise DeckDataError(f"chart {key}: note id {n.id} occurs twice")
291
+ seen.add(n.id)
292
+ try:
293
+ jt = score.judgement_type(n.op, n.crit)
294
+ except ValueError:
295
+ raise DeckDataError(f"chart {key}: note {n.id} has operate type {n.op}, which has no judgement type") \
296
+ from None
297
+ ids.append(int(n.id))
298
+ ops.append(int(n.op))
299
+ judgements.append(int(jt))
300
+ times.append(int(n.pos.ms))
301
+ return {
302
+ "scoreId": score_id,
303
+ "asset": {"key": key, "sha256": hashlib.sha256(raw).hexdigest()},
304
+ "notes": {"id": ids, "op": ops, "judgementType": judgements, "timeMs": times},
305
+ "skillEvents": {"timeMs": [int(p.ms) for _, p in rs.skills]},
306
+ "fevers": {"startMs": [int(a.ms) for _, a, _ in rs.fevers], "endMs": [int(b.ms) for _, _, b in rs.fevers]},
307
+ }
308
+
309
+
310
+ def charts(score_rows: list[dict], fetch: Callable[[str], bytes]) -> list[dict]:
311
+ """One record per MasterLiveMusicScore row, sorted by `_id`. `fetch(file name)`: the chart TextAsset's bytes;
312
+ KeyError when there is none."""
313
+ dup = sorted(i for i, n in Counter(r["_id"] for r in score_rows).items() if n > 1)
314
+ if dup:
315
+ raise DeckDataError(f"MasterLiveMusicScore: _id {dup[0]} occurs twice")
316
+ out = []
317
+ for r in sorted(score_rows, key=lambda r: r["_id"]):
318
+ key = chart_key(r["_musicScoreTextFileName"])
319
+ try:
320
+ raw = fetch(r["_musicScoreTextFileName"])
321
+ except KeyError:
322
+ raise DeckDataError(f"chart {key} (MasterLiveMusicScore {r['_id']}): no such asset") from None
323
+ out.append(chart_record(r["_id"], key, raw))
324
+ return out
325
+
326
+
327
+ def catalog_fetch(cat) -> Callable[[str], bytes]:
328
+ """fetch() for charts from a catalog: score.fetch_chart, KeyError for a key the catalog does not have."""
329
+ from . import score
330
+
331
+ def fetch(file_name: str) -> bytes:
332
+ if not cat.has(chart_key(file_name)):
333
+ raise KeyError(chart_key(file_name))
334
+ return score.fetch_chart(cat, file_name)
335
+ return fetch
336
+
337
+
338
+ # ---------------------------------------------------------------- provenance
339
+ def apk_client(apk) -> dict:
340
+ """{versionName, versionCode} of an APK's AndroidManifest.xml (None when absent)."""
341
+ from .player import MANIFEST_IN_APK, manifest_version_code, manifest_version_name
342
+ try:
343
+ with zipfile.ZipFile(apk) as z:
344
+ data = z.read(MANIFEST_IN_APK) if MANIFEST_IN_APK in z.namelist() else None
345
+ except (zipfile.BadZipFile, OSError):
346
+ raise DeckDataError(f"{apk}: not a readable APK") from None
347
+ if data is None:
348
+ return {"versionName": None, "versionCode": None}
349
+ return {"versionName": manifest_version_name(data), "versionCode": manifest_version_code(data)}
350
+
351
+
352
+ def catalog_info(cat, store_root=None) -> dict:
353
+ """{resourceVersion, sha256} of a catalog's remote catalog file (resource_version)."""
354
+ sha = hashlib.sha256(cat.sources()["remote"]).hexdigest()
355
+ return {"resourceVersion": resource_version(store_root, sha), "sha256": sha}
356
+
357
+
358
+ def resource_version(store_root, remote_sha: str) -> str | None:
359
+ """The resource version recorded for the remote catalog `remote_sha` by the catalog versions of a store
360
+ (the latest import that has one), None when none is recorded. Read only."""
361
+ if store_root is None:
362
+ return None
363
+ from .catalogdb import CatalogDB
364
+ try:
365
+ versions = CatalogDB(store_root).versions()
366
+ except (OSError, ValueError):
367
+ return None
368
+ for v in reversed(versions):
369
+ if v.get("remote", {}).get("sha256") == remote_sha and v.get("resourceVersion"):
370
+ return v["resourceVersion"]
371
+ return None
372
+
373
+
374
+ # ---------------------------------------------------------------- document
375
+ def build(tables: dict[str, list[dict]], table_sha: dict[str, str], fetch: Callable[[str], bytes], *, region: str,
376
+ client: dict, catalog: dict, master_source: str, master_version: str | None) -> dict:
377
+ """The deck data document. `catalog`: {resourceVersion, sha256} of the catalog the charts come from."""
378
+ from . import __version__
379
+ subset = master_subset(tables)
380
+ return {
381
+ "format": FORMAT,
382
+ "provenance": {
383
+ "region": region,
384
+ "client": {"versionName": client.get("versionName"), "versionCode": client.get("versionCode")},
385
+ "catalog": {"resourceVersion": catalog.get("resourceVersion"), "sha256": catalog.get("sha256")},
386
+ "master": {"source": master_source, "version": master_version,
387
+ "tables": {t: {"sha256": table_sha[t]} for t, _ in TABLES}},
388
+ "exporter": {"name": "nnnotes", "version": __version__, "chartFormat": CHART_FORMAT},
389
+ },
390
+ "master": subset,
391
+ "charts": charts(tables["MasterLiveMusicScore"], fetch),
392
+ }
393
+
394
+
395
+ def _emit(v, out: list) -> None:
396
+ if isinstance(v, _Num):
397
+ out.append(v)
398
+ elif isinstance(v, str):
399
+ out.append(json.dumps(v, ensure_ascii=False))
400
+ elif v is None:
401
+ out.append("null")
402
+ elif v is True:
403
+ out.append("true")
404
+ elif v is False:
405
+ out.append("false")
406
+ elif isinstance(v, int):
407
+ out.append(str(int(v)))
408
+ elif isinstance(v, dict):
409
+ out.append("{")
410
+ for i, (k, x) in enumerate(v.items()):
411
+ if not isinstance(k, str):
412
+ raise TypeError(f"key {k!r} is not a string")
413
+ if i:
414
+ out.append(",")
415
+ out.append(json.dumps(k, ensure_ascii=False))
416
+ out.append(":")
417
+ _emit(x, out)
418
+ out.append("}")
419
+ elif isinstance(v, (list, tuple)):
420
+ if all(type(x) is int for x in v):
421
+ out.append("[" + ",".join(map(str, v)) + "]")
422
+ return
423
+ out.append("[")
424
+ for i, x in enumerate(v):
425
+ if i:
426
+ out.append(",")
427
+ _emit(x, out)
428
+ out.append("]")
429
+ else: # a float must have been made a _Num (f32_token)
430
+ raise TypeError(f"no canonical form for {type(v).__name__}")
431
+
432
+
433
+ def encode(doc: dict) -> bytes:
434
+ """The canonical bytes of a document: minified UTF-8, keys in their order, one trailing LF."""
435
+ out: list[str] = []
436
+ _emit(doc, out)
437
+ out.append("\n")
438
+ return "".join(out).encode("utf-8")
439
+
440
+
441
+ def file_bytes(data: bytes, gz: bool) -> bytes:
442
+ """The bytes written: `data`, or with `gz` gzip with no file name and modification time 0."""
443
+ return gzip.compress(data, compresslevel=9, mtime=0) if gz else data
444
+
445
+
446
+ def export(out, src: MasterSource, key, fetch: Callable[[str], bytes], *, region: str, client: dict,
447
+ catalog: dict) -> dict:
448
+ """Read the master data and every chart, then write the file `out` (gzip when it ends in `.gz`) through a
449
+ temporary file and a rename. Returns the summary."""
450
+ from .cache import write_atomic
451
+ out = Path(out)
452
+ tables, shas = read_master(src, key)
453
+ doc = build(tables, shas, fetch, region=region, client=client, catalog=catalog, master_source=src.source,
454
+ master_version=src.version)
455
+ data = encode(doc)
456
+ written = file_bytes(data, out.name.endswith(".gz"))
457
+ out.parent.mkdir(parents=True, exist_ok=True)
458
+ write_atomic(out, written)
459
+ return {"out": str(out), "format": FORMAT, "region": region, "masterSource": src.source,
460
+ "masterVersion": src.version, "tables": len(TABLES), "rows": sum(len(r) for r in tables.values()),
461
+ "charts": len(doc["charts"]), "notes": sum(len(c["notes"]["id"]) for c in doc["charts"]),
462
+ "bytes": len(data), "fileBytes": len(written), "sha256": hashlib.sha256(written).hexdigest()}