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/web.py ADDED
@@ -0,0 +1,1624 @@
1
+ """Live charts -> one static site: the ournotes-player chart player plus per-chart data.
2
+
3
+ <site>/index.html, chart-list.js the player's chart-list page (the files of its PLAYER_PAGE_DIR, the
4
+ source import pointed at the bundle); without a query it lists the
5
+ charts, with ?music=<musicId>&difficulty=<d> it plays one
6
+ <site>/ournotes-player.element.min.js the player's built bundle (PLAYER_BUNDLES, with its source map)
7
+ <site>/charts.json the chart index (facts for a listing in every language, regions,
8
+ manifest path, sizes)
9
+ <site>/charts/<musicId>_<difficulty>.json chart manifest: every path the player reads -> {asset, size}, or,
10
+ for a large JSON object, {parts: [[key, asset, size], ...], size};
11
+ `regions`: the regions it serves
12
+ <site>/charts/<region>/<id>.json the manifest of a region whose chart inputs differ from the first
13
+ region's (only where its files differ)
14
+ <site>/assets/<sha256>.<ext> content-addressed files (text assets as UTF-8, JSON without
15
+ whitespace); charts share the note skins, effects, SE sheets, band
16
+ stages and the common parts of the scene, each stored once
17
+ <site>/models.json, models/<id>.json Live2D model index and manifests (webmodel.py), same entry forms and
18
+ the same assets/; write_index keeps what either kind references
19
+ <site>/live2d/ the player's Live2D model page and its bundle, when the player has
20
+ them (LIVE2D_PAGE_DIR, LIVE2D_BUNDLES)
21
+ <site>/stories.json, stories/<id>.json story index and manifests (storysite.py), same entry forms and the
22
+ same assets/; write_index keeps what they reference as well
23
+ <site>/story/ the player's story page and its bundle, when the player has them
24
+ (STORY_PAGE_DIR, STORY_BUNDLES)
25
+
26
+ Per music (the four difficulties share the scene, the sounds and the note assets): score + BGM decode, live sounds and
27
+ scene extracted once into a temporary base directory, the note assets once per build (they depend on no music); per
28
+ difficulty a directory of hard links to those plus its own score and start canvas, composed as `live.build` composes
29
+ a live directory. Per chart: the read set (READ_SET_SCRIPT of the player: the files the player reads for the whole
30
+ chart, its own code run in Node over the live directory; from the player's plan mode when it has one, checked
31
+ against the full run on a sample of charts, see ReadSets), those files collected (GLES3 shader programs only,
32
+ filtered shaders.json), the BGM transcoded to the web format (`audio_format`, once per music; note SE, cheers and
33
+ voices stay FLAC: small and shared), ingest. Musics run in parallel worker processes (catalog cache writes serialized
34
+ by a lock); temporary directories are deleted after use. A chart whose manifest exists is skipped unless `force`.
35
+ Same inputs give byte-identical outputs. `audio=False` exports no audio file (the player then runs the chart on its
36
+ own clock, silent).
37
+
38
+ Regions: one build can serve several regions. The regions serve the same catalog for a language, so the chart files
39
+ depend on a region's master data only: regions whose chart tables (LIVE_TABLES) are byte-identical share one manifest
40
+ (charts/<id>.json for the first region's group); another group's charts go to charts/<region>/<id>.json (region = the
41
+ first of the group), and such a manifest whose files equal the shared one's is dropped, its regions joining the
42
+ shared manifest. A region offers the charts its master data has a MasterLiveMusicScore row for. The listing texts
43
+ (title, band names) are in every language of the text tables (languages.LANGUAGES); `title` / `bands` in
44
+ [catalog] language.
45
+
46
+ Live options (`live_options`, from `--live-option`; liveoptions.py): a site can offer the Live options that select
47
+ files (mirrored score, note skins, effect sets and qualities, bar lines, note sound sets) besides the defaults. The
48
+ live directories then also carry those files (live.build's `options`), and each chart's read set is the union of the
49
+ default read set and the player's read sets with the settings of every offered variant (LiveOptions.read_variants;
50
+ the player's read-set script needs its "settings" feature); the full-simulation check of the plans runs on the
51
+ default settings. The manifests list the offered qualities (`options`). Without live options
52
+ the site holds only what the default options read.
53
+
54
+ A JSON object larger than SPLIT_MIN_BYTES is stored per top-level key: each value's text (the same minified encoding)
55
+ is its own asset, and the player rebuilds the file as `{"k1":` + t1 + `,"k2":` + t2 + `}`, so the values the charts
56
+ share (most of livescene/scene.json) are stored once.
57
+ """
58
+ from __future__ import annotations
59
+
60
+ import collections
61
+ import hashlib
62
+ import json
63
+ import multiprocessing as mp
64
+ import os
65
+ import queue
66
+ import re
67
+ import shutil
68
+ import struct
69
+ import subprocess
70
+ import sys
71
+ import tempfile
72
+ import threading
73
+ import time
74
+ import traceback
75
+ import uuid
76
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
77
+ from pathlib import Path
78
+ from types import SimpleNamespace
79
+
80
+ from . import cache, cri, jsonio, languages, live, liveoptions, livenotes, livescene
81
+ from .config import Config, ConfigError, tool, usable_cpus, use
82
+ from .score import DIFFICULTIES, master_table
83
+ from .webaudio import DEFAULT_AUDIO_FORMAT, WEB_AUDIO
84
+
85
+ SITE_FORMAT = 2
86
+ AUDIO_EXT = {".flac", ".ogg", ".opus", ".wav", ".m4a", ".mp3"}
87
+ SPLIT_MIN_BYTES = 512 * 1024
88
+ # the live directories of a web build: no liveui/ (the player reads none of it), the BGM decoded once for the read
89
+ # set (FLAC) and, from the same samples, into the site format (bgm_options)
90
+ WEB_LIVEUI = False
91
+ BGM_DIRECT = True
92
+ SPLIT_KEY = re.compile(r"[A-Za-z0-9_$.\-]+") # keys whose JSON text is the same in Python and JavaScript
93
+ TRACE_QUALITY = 1 # the quality the read set is taken at (the player default, Middle)
94
+ FLOWS = ("direct",) # the start flow the player has
95
+ # files of the ournotes-player checkout / package the site uses
96
+ READ_SET_SCRIPT = "scripts/read-set.mjs" # node <script> <live dir> -> JSON list of the paths the player reads
97
+ PLAYER_BUNDLES = ("dist/ournotes-player.element.min.js",)
98
+ PLAYER_PAGE_DIR = "examples/chart-list"
99
+ # module specifiers of the page that name the player's sources -> the bundle copied next to the page
100
+ PAGE_IMPORTS = {"../../src/element.js": "./ournotes-player.element.min.js"}
101
+ # the player's Live2D model page (when the player has it): its files and bundle go to SITE/<LIVE2D_PAGE_SITE_DIR>/
102
+ LIVE2D_PAGE_DIR = "examples/live2d"
103
+ LIVE2D_PAGE_SITE_DIR = "live2d"
104
+ LIVE2D_BUNDLES = ("dist/ournotes-player.live2d.element.min.js",)
105
+ LIVE2D_PAGE_IMPORTS = {"../../src/live2d/define.js": "./ournotes-player.live2d.element.min.js"}
106
+ # the player's story page (when the player has it and its bundle): its files and bundle go to SITE/<STORY_PAGE_SITE_DIR>/
107
+ STORY_PAGE_DIR = "examples/story-list"
108
+ STORY_PAGE_SITE_DIR = "story"
109
+ STORY_BUNDLES = ("dist/ournotes-player.story.element.min.js",)
110
+ STORY_PAGE_IMPORTS = {"../../src/story/define.js": "./ournotes-player.story.element.min.js"}
111
+ # site data written by the build (never overwritten by page files)
112
+ MODELS_DIR = "models"
113
+ MODELS_INDEX = "models.json"
114
+ STORIES_DIR = "stories"
115
+ STORIES_INDEX = "stories.json"
116
+ SITE_DATA = ("assets", "charts", MODELS_DIR, "charts.json", MODELS_INDEX, STORIES_DIR, STORIES_INDEX)
117
+ PLAYER_VERSION_MARKER = "@PLAYER_VERSION@" # replaced in the page's files by the bundles' short hash
118
+ # collected files: text as UTF-8, binary as is; shader programs of the WebGL2 tier only
119
+ TEXT_EXT = {".json", ".glsl"}
120
+ BINARY_EXT = {".png", ".flac", ".ogg", ".wav", ".moc3", ".glb", ".atlas", ".skel", ".bin"} # .glb / .atlas / .skel: an Overlay story's home spot; .bin: a story's CRI Lips data (crilips.py)
121
+ SHADER_PLATFORM = "gles3"
122
+ SHADER_TYPE = "GLES3"
123
+ # master tables the chart build reads (score, live, liveaudio, livescene, livenotes, liveui): a region's chart inputs
124
+ LIVE_TABLES = ("MasterBand", "MasterCharacter", "MasterLiveJudgementSprite", "MasterLiveLaneSkin", "MasterLiveMusic",
125
+ "MasterLiveMusicScore", "MasterLiveNoteEffectSkin", "MasterLiveNoteSe", "MasterLiveNoteSkin",
126
+ "MasterLiveQualitySettings", "MasterLiveSe", "MasterLiveSettings", "MasterLiveStageVideo",
127
+ "MasterLiveStartCharacterVoice", "MasterMemberCard", "MasterOptionDefault", "MasterOptionRange",
128
+ "MasterSound", "MasterSoundCueSheet", "MasterText")
129
+ CHARTS_INDEX = "charts.json"
130
+ # documentation fields of the extractors the player does not read (live-audio.json `spec`, the voice rule notes,
131
+ # scene.json `slice` / `derived` notes): left out of the site's copies
132
+ DOC_FIELDS = {
133
+ "audio/live-audio.json": [("spec",), ("voice", "rule"), ("voice", "decision")],
134
+ "livescene/scene.json": [("slice", "bandChoice", "source"), ("slice", "bandChoice", "note"),
135
+ ("derived", "lane", "sources"), ("derived", "fovRule", "source")],
136
+ }
137
+
138
+
139
+ def _dump(obj) -> bytes:
140
+ return jsonio.dumps(obj, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n"
141
+
142
+
143
+ def _minify(obj) -> str:
144
+ return jsonio.dumps(obj, ensure_ascii=False, separators=(",", ":"))
145
+
146
+
147
+ def text_asset(path: str, s: str) -> bytes:
148
+ """A text asset's stored bytes: JSON re-encoded without whitespace (the same values: JSON.parse of either text
149
+ gives the same result; non-finite numbers stay 1e999 / -1e999) and without the DOC_FIELDS, other text (GLSL)
150
+ as is, UTF-8."""
151
+ if path.endswith(".json"):
152
+ obj = json.loads(s)
153
+ for keys in DOC_FIELDS.get(path, ()):
154
+ o = obj
155
+ for k in keys[:-1]:
156
+ o = o.get(k) if isinstance(o, dict) else None
157
+ if isinstance(o, dict):
158
+ o.pop(keys[-1], None)
159
+ s = _minify(obj)
160
+ return s.encode("utf-8")
161
+
162
+
163
+ def join_parts(parts: list[tuple[str, bytes]]) -> bytes:
164
+ """The text the player's AssetStore rebuilds from a split JSON file: {"k1":t1,"k2":t2}."""
165
+ return b"{" + b",".join(json.dumps(k).encode("ascii") + b":" + t for k, t in parts) + b"}"
166
+
167
+
168
+ def split_json(data: bytes) -> list[tuple[str, bytes]] | None:
169
+ """A minified JSON object larger than SPLIT_MIN_BYTES -> [(key, value text)] in key order; None when it stays
170
+ whole. The parts rejoin to exactly `data`."""
171
+ if len(data) <= SPLIT_MIN_BYTES or not data.startswith(b"{"):
172
+ return None
173
+ obj = json.loads(data)
174
+ if not isinstance(obj, dict) or len(obj) < 2 or not all(SPLIT_KEY.fullmatch(k) for k in obj):
175
+ return None
176
+ parts = [(k, _minify(v).encode("utf-8")) for k, v in obj.items()]
177
+ if join_parts(parts) != data:
178
+ raise RuntimeError("split JSON does not rejoin to the stored text")
179
+ return parts
180
+
181
+
182
+ # ---------------------------------------------------------------- audio
183
+ def transcode(src: Path, fmt: str, work: Path) -> bytes:
184
+ """One FLAC waveform -> the web format's bytes (bit-exact container flags: the output depends on the input only)."""
185
+ ext, codec = WEB_AUDIO[fmt]
186
+ if codec is None:
187
+ return Path(src).read_bytes()
188
+ dst = Path(work) / f"transcode-{os.getpid()}{ext}"
189
+ r = subprocess.run([tool("ffmpeg", "ffmpeg"), "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), "-map_metadata", "-1",
190
+ "-fflags", "+bitexact", "-flags:a", "+bitexact", *codec, str(dst)],
191
+ capture_output=True, text=True)
192
+ if r.returncode != 0:
193
+ raise RuntimeError(f"ffmpeg {fmt} failed on {src}: {r.stderr[-2000:]}")
194
+ data = dst.read_bytes()
195
+ dst.unlink()
196
+ return data
197
+
198
+
199
+ def mp4_priming(data: bytes) -> int:
200
+ """Encoder delay of an MP4 audio track: media_time of the first edit list entry (moov/trak/edts/elst)."""
201
+ def boxes(b, off, end):
202
+ while off + 8 <= end:
203
+ size, kind = struct.unpack(">I4s", b[off:off + 8])
204
+ hdr = 8
205
+ if size == 1:
206
+ size, hdr = struct.unpack(">Q", b[off + 8:off + 16])[0], 16
207
+ elif size == 0:
208
+ size = end - off
209
+ yield kind, off + hdr, off + size
210
+ off += size
211
+ def find(path, off, end):
212
+ for kind, a, z in boxes(data, off, end):
213
+ if kind == path[0]:
214
+ return (a, z) if len(path) == 1 else find(path[1:], a, z)
215
+ return None
216
+ loc = find([b"moov", b"trak", b"edts", b"elst"], 0, len(data))
217
+ if loc is None:
218
+ return 0
219
+ a, _ = loc
220
+ version = data[a]
221
+ count = struct.unpack(">I", data[a + 4:a + 8])[0]
222
+ if count < 1:
223
+ return 0
224
+ if version == 1:
225
+ return max(0, struct.unpack(">q", data[a + 16:a + 24])[0])
226
+ return max(0, struct.unpack(">i", data[a + 12:a + 16])[0])
227
+
228
+
229
+ def _web_audio(live_dir: Path, text: dict[str, str], binary: dict[str, bytes], fmt: str, work: Path,
230
+ cache: dict | None = None) -> None:
231
+ """The BGM layers (live-audio.json music sound) in the web format; the JSON paths follow. An AAC layer records
232
+ its encoder delay (`encoderDelay`, samples): a decoder that does not apply the MP4 edit list outputs that many
233
+ priming samples first, which the player then drops (LiveSoundManager.preload). `cache`: FLAC sha256 -> bytes."""
234
+ ext = WEB_AUDIO[fmt][0]
235
+ index = json.loads(text["live.json"])
236
+ la_path = index["liveAudio"]
237
+ la = json.loads(text[la_path])
238
+ renamed = {}
239
+ for layer in la["sounds"][str(la["music"]["soundId"])]["layers"]:
240
+ old = layer["file"]
241
+ if old not in binary or not old.endswith(".flac"):
242
+ raise ValueError(f"BGM layer {old}: not a packed FLAC")
243
+ new = old[:-len(".flac")] + ext
244
+ if new != old:
245
+ key = hashlib.sha256(binary[old]).hexdigest()
246
+ data = cache.get(key) if cache is not None else None
247
+ if data is None:
248
+ pre = live_dir / new # made from the same samples by the decode (bgm_options)
249
+ data = pre.read_bytes() if pre.is_file() else transcode(live_dir / old, fmt, work)
250
+ if cache is not None:
251
+ cache[key] = data
252
+ binary[new] = data
253
+ del binary[old]
254
+ renamed[old] = new
255
+ if fmt == "aac":
256
+ layer["encoderDelay"] = mp4_priming(data)
257
+ layer["file"] = new
258
+ if not renamed:
259
+ return
260
+ text[la_path] = jsonio.dumps(la, ensure_ascii=False, indent=1)
261
+ a = index.get("audio") or {}
262
+ if a.get("file") in renamed:
263
+ a["file"] = renamed[a["file"]]
264
+ for k, v in (a.get("cues") or {}).items():
265
+ if v in renamed:
266
+ a["cues"][k] = renamed[v]
267
+ text["live.json"] = jsonio.dumps(index, ensure_ascii=False, indent=1)
268
+
269
+
270
+ def localized_texts(master: dict) -> tuple[dict, dict]:
271
+ """({language: title}, {language: [band names]}) of a live's master rows (score/master.json), every language of
272
+ languages.LANGUAGES the texts have (for the band names: every band has one)."""
273
+ title = master.get("title") or {}
274
+ bands = [b.get("name") or {} for b in master.get("bands") or []]
275
+ titles = {c: title[c] for c in languages.LANGUAGES if isinstance(title.get(c), str)}
276
+ names = {c: [b[c] for b in bands] for c in languages.LANGUAGES if all(isinstance(b.get(c), str) for b in bands)}
277
+ return titles, names
278
+
279
+
280
+ def chart_facts(live_dir: Path, summary: dict, difficulty: str, language: str) -> dict:
281
+ """What a chart listing needs, from the live directory (master rows, sounds) and the build summary: `title` and
282
+ `bands` in `language`, `titles` and `bandNames` in every language."""
283
+ rd = lambda rel: json.loads((Path(live_dir) / rel).read_text(encoding="utf-8"))
284
+ index = rd("live.json")
285
+ master = rd(index["master"])
286
+ la = rd(index["liveAudio"])
287
+ layer = la["sounds"][str(la["music"]["soundId"])]["layers"][0]
288
+ score_row = master["MasterLiveMusicScore"][difficulty]
289
+ music = master["MasterLiveMusic"]
290
+ titles, band_names = localized_texts(master)
291
+ return {
292
+ "title": (master.get("title") or {}).get(language),
293
+ "bands": [(b.get("name") or {}).get(language) for b in master.get("bands") or []],
294
+ "language": language,
295
+ "titles": titles,
296
+ "bandNames": band_names,
297
+ "bandIds": list(music.get("_bandIDs") or []),
298
+ "stageBand": summary["band"]["band"],
299
+ "level": score_row["_musicScoreLevel"],
300
+ "displayLevel": score_row["_musicScoreDisplayLevel"],
301
+ "notes": summary["judgementNoteCount"],
302
+ "fullComboCount": summary["fullComboCount"],
303
+ "durationMs": layer["samples"] * 1000 // layer["sampleRate"],
304
+ "sortOrder": music.get("_sortOrder"),
305
+ }
306
+
307
+
308
+ # ---------------------------------------------------------------- store
309
+ class Store:
310
+ """site/assets/<sha256>.<ext>: write-once content-addressed files (safe for concurrent writers)."""
311
+
312
+ def __init__(self, site: Path):
313
+ self.dir = Path(site) / "assets"
314
+ self.dir.mkdir(parents=True, exist_ok=True)
315
+ self.written = 0
316
+ self.reused = 0
317
+ self._have: set[str] = set() # names this store has written or found
318
+
319
+ def put(self, path: str, data: bytes) -> dict:
320
+ ext = Path(path).suffix.lower().lstrip(".") or "bin"
321
+ name = f"{hashlib.sha256(data).hexdigest()}.{ext}"
322
+ dst = self.dir / name
323
+ if name in self._have:
324
+ self.reused += 1
325
+ elif dst.exists() and dst.stat().st_size == len(data):
326
+ self.reused += 1
327
+ self._have.add(name)
328
+ else:
329
+ tmp = cache.temp_path(dst)
330
+ tmp.write_bytes(data)
331
+ for i in range(20):
332
+ try:
333
+ os.replace(tmp, dst)
334
+ break
335
+ except PermissionError:
336
+ if dst.exists() and dst.stat().st_size == len(data):
337
+ tmp.unlink()
338
+ break
339
+ time.sleep(0.1 * (i + 1))
340
+ else:
341
+ raise RuntimeError(f"could not store {dst}")
342
+ self.written += 1
343
+ self._have.add(name)
344
+ return {"asset": f"assets/{name}", "size": len(data)}
345
+
346
+ def put_file(self, path: str, data: bytes) -> dict:
347
+ """One file of a chart: whole, or per top-level key for a large JSON object (see split_json)."""
348
+ parts = split_json(data) if path.endswith(".json") else None
349
+ if parts is None:
350
+ return self.put(path, data)
351
+ return {"size": len(data), "parts": [[k, self.put(f"{path}#{k}.json", t)["asset"], len(t)] for k, t in parts]}
352
+
353
+
354
+ def entry_assets(e: dict) -> list[str]:
355
+ return [p[1] for p in e["parts"]] if "parts" in e else [e["asset"]]
356
+
357
+
358
+ _TEXTS = cache.bucket("webtext") # (build, store, path, text) -> manifest entry
359
+
360
+
361
+ def stored_text(store: Store, path: str, s: str, memo: str | None = None) -> dict:
362
+ """store.put_file(path, text_asset(path, s)), made once per text in this process during the build `memo` (the
363
+ charts of a music share most files, every chart the note assets)."""
364
+ if memo is None:
365
+ return store.put_file(path, text_asset(path, s))
366
+ k = _TEXTS.key(memo, str(store.dir), path, s)
367
+ hit = _TEXTS.get(k)
368
+ if hit is None:
369
+ e = store.put_file(path, text_asset(path, s))
370
+ _TEXTS.put(k, json.dumps(e), 256)
371
+ return e
372
+ return json.loads(hit)
373
+
374
+
375
+ # ---------------------------------------------------------------- data
376
+ def open_data(cfg: Config, region: str | None = None):
377
+ """Catalog (fetching from the CDN of `region`), the master dir of `region` and PlayerData from the settings (as
378
+ the command line opens them; `region` None: [catalog] region)."""
379
+ from .cli import master_dir, open_catalog, player_data
380
+ return open_catalog(cfg, region=region), master_dir(cfg, region), player_data(cfg)
381
+
382
+
383
+ def _lock_fetches(cat, lock) -> None:
384
+ """Catalog downloads of this process under a lock shared by the workers (one writer per cache file); a file
385
+ already in the cache is returned without the lock (Catalog.cached)."""
386
+ for name in ("fetch", "fetch_raw"):
387
+ f = getattr(cat, name)
388
+ def locked(x, _f=f, _name=name):
389
+ hit = cat.cached(x) if _name == "fetch" else cat.cached_raw(x)
390
+ if hit is not None:
391
+ return hit
392
+ with lock:
393
+ return _f(x)
394
+ setattr(cat, name, locked)
395
+
396
+
397
+ def all_pairs(master: Path) -> list[tuple[int, str]]:
398
+ """Every MasterLiveMusic x difficulty with a MasterLiveMusicScore row, in music id order."""
399
+ scores = {r["_id"] for r in master_table(Path(master), "MasterLiveMusicScore")}
400
+ out = []
401
+ for m in sorted(master_table(Path(master), "MasterLiveMusic"), key=lambda r: r["_id"]):
402
+ out += [(m["_id"], d) for d in DIFFICULTIES if m.get(f"_{d}ID") in scores]
403
+ return out
404
+
405
+
406
+ # ---------------------------------------------------------------- regions
407
+ def site_regions(cfg: Config, regions=None, all_regions: bool = False) -> list[str]:
408
+ """The regions of a build: `regions` (given order, duplicates dropped), every configured region
409
+ (`all_regions`, config order), else the one [catalog] region. Each needs its `[servers.<region>]` table."""
410
+ if all_regions:
411
+ names = cfg.regions()
412
+ if not names:
413
+ raise ConfigError("no region configured: add a [servers.<region>] table with `cdn` to the config file "
414
+ "(or set NNNOTES_SERVERS_<REGION>_CDN)")
415
+ else:
416
+ names = list(dict.fromkeys(regions or [])) or [cfg.region()]
417
+ known = set(cfg.regions())
418
+ for r in names:
419
+ if r not in known:
420
+ raise cfg.missing(f"servers.{r}", "cdn")
421
+ return names
422
+
423
+
424
+ def unknown_pairs(cfg: Config, pairs, regions=None) -> list[tuple[int, str]]:
425
+ """The charts of `pairs` ([(musicId, difficulty)], duplicates dropped) that no region of the site
426
+ (site_regions(cfg, regions)) has: no MasterLiveMusicScore row for that music and difficulty in its master data."""
427
+ have: set = set()
428
+ for md in dict.fromkeys(region_masters(cfg, site_regions(cfg, regions)).values()):
429
+ try:
430
+ have.update(all_pairs(md))
431
+ except FileNotFoundError as e:
432
+ raise ConfigError(f"master data {md}: no {Path(e.filename).name} (a directory written by "
433
+ f"`nnnotes master decode`)") from None
434
+ return [p for p in dict.fromkeys((int(m), d) for m, d in pairs) if p not in have]
435
+
436
+
437
+ def region_masters(cfg: Config, regions: list[str]) -> dict[str, Path]:
438
+ """{region: decoded master dir} (cli.master_dir). With more than one region the --master flag is refused and
439
+ at most one region may use [paths] master (the others need their own `[servers.<region>] master`)."""
440
+ from .cli import master_dir
441
+ if len(regions) > 1 and cfg.origin("paths", "master") == "flag":
442
+ raise ConfigError("--master names one directory: give each region's master data as `master` in its "
443
+ "[servers.<region>] table of the config file (NNNOTES_SERVERS_<REGION>_MASTER)")
444
+ shared = [r for r in regions if cfg.master(r) == ("paths", "master")]
445
+ if len(shared) > 1:
446
+ raise cfg.missing(f"servers.{shared[1]}", "master")
447
+ return {r: master_dir(cfg, r) for r in regions}
448
+
449
+
450
+ def chart_inputs(master: Path) -> str:
451
+ """Fingerprint of a region's chart inputs: SHA-256 over the LIVE_TABLES files of its master dir."""
452
+ h = hashlib.sha256()
453
+ for t in LIVE_TABLES:
454
+ f = Path(master) / f"{t}.json"
455
+ h.update(t.encode("ascii") + b"\0" + (f.read_bytes() if f.is_file() else b"\0missing") + b"\0")
456
+ return h.hexdigest()
457
+
458
+
459
+ def region_groups(masters: dict[str, Path]) -> list[list[str]]:
460
+ """The regions grouped by chart inputs (chart_inputs), groups and regions in the order of `masters`."""
461
+ groups: dict[str, list[str]] = {}
462
+ for r, m in masters.items():
463
+ groups.setdefault(chart_inputs(m), []).append(r)
464
+ return list(groups.values())
465
+
466
+
467
+ def region_meta(cfg: Config, regions: list[str]) -> list[dict]:
468
+ """The index records of `regions`: id, name (`[servers.<region>] name`, else the id) and, when configured, the
469
+ client languages (`[servers.<region>] languages`)."""
470
+ out = []
471
+ for r in regions:
472
+ rec = {"id": r, "name": cfg.get(f"servers.{r}", "name") or r}
473
+ langs = cfg.get_list(f"servers.{r}", "languages")
474
+ if langs:
475
+ rec["languages"] = langs
476
+ out.append(rec)
477
+ return out
478
+
479
+
480
+ def merge_regions(old, new) -> list[str]:
481
+ """`old` (a manifest's regions, may be None) followed by the regions of `new` it lacks."""
482
+ out = list(old or [])
483
+ return out + [r for r in new if r not in out]
484
+
485
+
486
+ def manifest_file(site: Path, chart_id: str, prefix: str = "") -> Path:
487
+ """charts/<prefix><chart id>.json (`prefix`: "" or "<region>/")."""
488
+ return Path(site) / "charts" / f"{prefix}{chart_id}.json"
489
+
490
+
491
+ def chart_manifests(site: Path) -> list[Path]:
492
+ """Every chart manifest: charts/*.json, then the region manifests charts/<region>/*.json."""
493
+ d = Path(site) / "charts"
494
+ return [*sorted(d.glob("*.json")), *sorted(d.glob("*/*.json"))]
495
+
496
+
497
+ def add_regions(path: Path, regions: list[str]) -> bool:
498
+ """Add `regions` to a manifest's regions; True when the file changed."""
499
+ man = json.loads(Path(path).read_text(encoding="utf-8"))
500
+ merged = merge_regions(man.get("regions"), regions)
501
+ if merged == man.get("regions"):
502
+ return False
503
+ man["regions"] = merged
504
+ Path(path).write_bytes(_dump(man))
505
+ return True
506
+
507
+
508
+ def fold_variant(site: Path, chart_id: str, prefix: str) -> bool:
509
+ """Drop the region manifest charts/<prefix><id>.json when its files (and offered live options) equal those of
510
+ the shared charts/<id>.json; its regions join the shared manifest. True when it was dropped."""
511
+ var, shared = manifest_file(site, chart_id, prefix), manifest_file(site, chart_id)
512
+ if not (prefix and var.is_file() and shared.is_file()):
513
+ return False
514
+ v, s = json.loads(var.read_text(encoding="utf-8")), json.loads(shared.read_text(encoding="utf-8"))
515
+ if v["files"] != s["files"] or v.get("options") != s.get("options"):
516
+ return False
517
+ add_regions(shared, v.get("regions") or [])
518
+ var.unlink()
519
+ return True
520
+
521
+
522
+ # ---------------------------------------------------------------- live directories
523
+ def _link_tree(src: Path, dst: Path) -> None:
524
+ shutil.copytree(src, dst, copy_function=os.link)
525
+
526
+
527
+ def bgm_options(audio_format: str, audio: bool) -> dict:
528
+ """cri.decode options of a web build's BGM: the live directory keeps FLAC (the read set reads its header only:
529
+ level 0 unless the site stores that FLAC) and, when the site takes another format, the same samples encoded
530
+ into it as well (BGM_DIRECT; _web_audio then stores that file)."""
531
+ if not audio:
532
+ return {"flac_level": 0}
533
+ ext, codec = WEB_AUDIO[audio_format]
534
+ if codec is None:
535
+ return {}
536
+ return {"flac_level": 0, **({"also": (ext, codec)} if BGM_DIRECT else {})}
537
+
538
+
539
+ def build_music_dirs(cat, master: Path, player, music_id: int, difficulties: list[str], root: Path,
540
+ livenotes_dir: Path, band: int | None = None, leader_card: int | None = None, *,
541
+ language: str, fonts: str = "open", scene=None, bgm: dict | None = None,
542
+ options: liveoptions.LiveOptions = liveoptions.LiveOptions()) -> dict:
543
+ """The live directories of one music's charts under root/<difficulty>/, composed as live.build composes one:
544
+ BGM decode (bgm: cri.decode options), live sounds and scene into root/base, shared by hard links; per difficulty
545
+ its own score/ (score.extract without audio) and live.json (liveui/ only with WEB_LIVEUI); livenotes/ linked
546
+ from `livenotes_dir` (livenotes.extract reads no music input). `scene`: (band part, its files) of the music's
547
+ band (livescene.band_part), else the scene is exported whole. `band` / `leader_card` / `language` / `fonts` /
548
+ `options`: as for live.build (livenotes_dir made with the same options). Returns {difficulty: (dir, summary)}."""
549
+ from . import liveaudio, liveui, score
550
+ base = root / "base"
551
+ base.mkdir(parents=True)
552
+ choice = live.resolve_band(cat, master, music_id, band=band, leader_card=leader_card)
553
+ first = {"audio": score.extract_audio(cat, master, music_id, base, "flac", **(bgm or {}))}
554
+ la = liveaudio.extract(cat, master, player, music_id, base, fmt="flac", options=options)
555
+ mode = "whole (no band part)" # how the scene was made: reported per chart
556
+ if scene is not None and scene[0]["band"] == choice["band"]:
557
+ _link_tree(scene[1], base / "livescene")
558
+ try:
559
+ livescene.music_part(cat, player, base, scene[0], master, music_id, band_choice=choice)
560
+ mode = "band"
561
+ except livescene.SharedSceneMismatch as e:
562
+ mode = f"whole ({e})"
563
+ _log(f"{music_id}: scene exported whole: {e}")
564
+ shutil.rmtree(base / "livescene")
565
+ if mode != "band":
566
+ livescene.extract(cat, player, base, master=master, music_id=music_id, band=choice["band"],
567
+ band_choice=choice)
568
+ out = {}
569
+ for d in difficulties:
570
+ pdir = root / d
571
+ pdir.mkdir()
572
+ for sub in ("audio", "livescene"):
573
+ _link_tree(base / sub, pdir / sub)
574
+ _link_tree(livenotes_dir, pdir / "livenotes")
575
+ s = score.extract(cat, master, music_id, d, pdir, audio=False, audio_fmt="flac", mirror=options.mirror)
576
+ if WEB_LIVEUI:
577
+ liveui.extract(cat, player, pdir, master=master, music_id=music_id, difficulty=d, language=language,
578
+ fonts=fonts)
579
+ jsonio.write_json(pdir / "live.json", live.index_doc(music_id, d, s, first["audio"], la["index"]))
580
+ out[d] = (pdir, {"band": choice, "judgementNoteCount": s["judgementNoteCount"],
581
+ "fullComboCount": s["fullComboCount"], "scene": mode})
582
+ return out
583
+
584
+
585
+ def collect(live_dir: Path, files: list[str]) -> tuple[dict[str, str], dict[str, bytes]]:
586
+ """The files `files` (paths relative to `live_dir`, every one must exist) as ({path: text}, {path: bytes}).
587
+ Every shader index (<dir>/shaders.json) among them is filtered to the listed GLES3 programs (GLSL ES 3.00);
588
+ other programs and SPIR-V containers of a shader directory are left out."""
589
+ live_dir = Path(live_dir)
590
+ exact = set(files)
591
+ wanted = exact.__contains__
592
+ lost = sorted(r for r in exact if not (live_dir / r).is_file())
593
+ if lost:
594
+ raise FileNotFoundError(f"{live_dir}: {len(lost)} listed files missing, e.g. {lost[:3]}")
595
+ text: dict[str, str] = {}
596
+ keep: set[str] = set()
597
+ shader_dirs = sorted(p.parent for p in live_dir.rglob("shaders.json"))
598
+ for sdir in shader_dirs:
599
+ rel_dir = sdir.relative_to(live_dir).as_posix()
600
+ if not wanted(f"{rel_dir}/shaders.json"):
601
+ continue
602
+ index = json.loads((sdir / "shaders.json").read_text(encoding="utf-8"))
603
+ for rec in index:
604
+ rec["variants"] = [v for v in rec["variants"]
605
+ if v["platform"] == SHADER_PLATFORM and v["type"] == SHADER_TYPE]
606
+ index = [rec for rec in index if wanted(f"{rel_dir}/{rec['parsed']}")]
607
+ for rec in index:
608
+ rec["variants"] = [v for v in rec["variants"] if wanted(f"{rel_dir}/{v['file']}")]
609
+ keep |= {f"{rel_dir}/{v['file']}" for rec in index for v in rec["variants"]}
610
+ text[f"{rel_dir}/shaders.json"] = jsonio.dumps(index, ensure_ascii=False)
611
+ binary: dict[str, bytes] = {}
612
+ for f in sorted(live_dir.rglob("*")):
613
+ if not f.is_file():
614
+ continue
615
+ rel = f.relative_to(live_dir).as_posix()
616
+ if rel.endswith(".html") or rel in text or not wanted(rel):
617
+ continue
618
+ sdir = next((d for d in shader_dirs if f.is_relative_to(d)), None)
619
+ if sdir is not None and f.suffix == ".glsl" and rel not in keep:
620
+ continue # other platforms / GLES 3.1 programs
621
+ if sdir is not None and f.suffix not in (".glsl", ".json"):
622
+ continue # Vulkan / SPIR-V containers
623
+ if f.suffix in TEXT_EXT:
624
+ text[rel] = f.read_text(encoding="utf-8")
625
+ elif f.suffix in BINARY_EXT:
626
+ binary[rel] = f.read_bytes()
627
+ else:
628
+ raise ValueError(f"unexpected file in the live directory: {rel}")
629
+ return text, binary
630
+
631
+
632
+ def ingest(store: Store, site: Path, music_id: int, difficulty: str, live_dir: Path, summary: dict,
633
+ files: list[str], flows: list[str], audio_format: str, audio: bool, work: Path, bgm_cache: dict,
634
+ language: str, prefix: str = "", regions: list[str] | None = None, memo: str | None = None,
635
+ options: liveoptions.LiveOptions | None = None) -> dict:
636
+ """One chart's files into the store + its manifest charts/<prefix><id>.json, serving `regions` (added to the
637
+ regions of the manifest it replaces). `memo`: the build's id for stored_text. `options`: the live options the
638
+ files carry (the manifest's `options`: LiveOptions.manifest_options)."""
639
+ text, binary = collect(live_dir, files)
640
+ facts = chart_facts(live_dir, summary, difficulty, language)
641
+ if audio:
642
+ _web_audio(live_dir, text, binary, audio_format, work, bgm_cache)
643
+ else:
644
+ binary = {k: v for k, v in binary.items() if Path(k).suffix.lower() not in AUDIO_EXT}
645
+ entries = {p: stored_text(store, p, s, memo) for p, s in text.items()}
646
+ entries.update({p: store.put(p, b) for p, b in binary.items()})
647
+ offered = options.manifest_options() if options else None
648
+ manifest = {"format": SITE_FORMAT, "musicId": music_id, "difficulty": difficulty, "audio": bool(audio),
649
+ "audioFormat": audio_format if audio else None, "flows": flows, "quality": TRACE_QUALITY,
650
+ **({"options": offered} if offered else {}), "chart": facts, "files": dict(sorted(entries.items()))}
651
+ path = manifest_file(site, f"{music_id}_{difficulty}", prefix)
652
+ old = json.loads(path.read_text(encoding="utf-8")).get("regions") if path.is_file() else None
653
+ if old or regions:
654
+ manifest["regions"] = merge_regions(old, regions or [])
655
+ path.parent.mkdir(parents=True, exist_ok=True)
656
+ path.write_bytes(_dump(manifest))
657
+ return {"id": f"{music_id}_{difficulty}", "ok": True, "files": len(entries), "bytes": sum(e["size"] for e in entries.values()),
658
+ "flows": flows}
659
+
660
+
661
+ # ---------------------------------------------------------------- per music (in a worker or in this process)
662
+ _W: dict = {}
663
+
664
+
665
+ def _worker_init(cfg: Config, lock, job: dict) -> None:
666
+ use(cfg)
667
+ configure_caches(job)
668
+ cat, master, player = open_data(cfg, job.get("region"))
669
+ if lock is not None:
670
+ _lock_fetches(cat, lock)
671
+ _W.update(cat=cat, master=master, player=player, cfg=job, scenes={})
672
+
673
+
674
+ def configure_caches(job: dict) -> None:
675
+ """The process caches of a build: the disk layer under the build's temporary root (shared by its processes)."""
676
+ if job.get("cache"):
677
+ cache.configure(directory=job["cache"])
678
+
679
+
680
+ def _log(m: str) -> None:
681
+ print(f"[{time.strftime('%H:%M:%S')}] {m}", file=sys.stderr, flush=True)
682
+
683
+
684
+ def _data(data=None):
685
+ return data or (_W["cat"], _W["master"], _W["player"])
686
+
687
+
688
+ def _scene_part(cfg: dict, band: int):
689
+ """(band part document, its files) of `band` from the build's scene directory, None when it has none."""
690
+ d = Path(cfg["scenes"]) / str(band) if cfg.get("scenes") else None
691
+ if d is None or not (d / livescene.BAND_DOC).is_file():
692
+ return None
693
+ parts = _W.setdefault("scenes", {})
694
+ if band not in parts:
695
+ parts[band] = json.loads((d / livescene.BAND_DOC).read_text(encoding="utf-8"))
696
+ return parts[band], d / "livescene"
697
+
698
+
699
+ def band_task(band: int, music_id: int, cfg: dict | None = None, data=None) -> str:
700
+ """The scene of `band` (livescene.band_part) into <scenes>/<band>/; `music_id`: a music of that band."""
701
+ cat, master, player = _data(data)
702
+ cfg = cfg or _W["cfg"]
703
+ out = Path(cfg["scenes"]) / str(band)
704
+ livescene.band_part(cat, player, out, master, music_id, band)
705
+ return str(out)
706
+
707
+
708
+ def extract_task(music_id: int, difficulties: list[str], cfg: dict | None = None, data=None) -> dict:
709
+ """The live directories of one music under a new temporary root: {music, root, dirs {d: (dir, summary)},
710
+ extractSec}. The root is removed on failure (else by the caller after ingest)."""
711
+ cat, master, player = _data(data)
712
+ cfg = cfg or _W["cfg"]
713
+ root = Path(tempfile.mkdtemp(prefix=f"m{music_id}-", dir=cfg["tmp"]))
714
+ t0 = time.time()
715
+ try:
716
+ choice = live.resolve_band(cat, master, music_id, band=cfg["band"], leader_card=cfg["leaderCard"])
717
+ dirs = build_music_dirs(cat, master, player, music_id, difficulties, root, Path(cfg["livenotes"]),
718
+ band=cfg["band"], leader_card=cfg["leaderCard"], language=cfg["language"],
719
+ fonts=cfg.get("fonts", "open"), scene=_scene_part(cfg, choice["band"]),
720
+ bgm=bgm_options(cfg["audioFormat"], cfg["audio"]),
721
+ options=cfg.get("options") or liveoptions.LiveOptions())
722
+ except BaseException:
723
+ shutil.rmtree(root, ignore_errors=True)
724
+ raise
725
+ return {"music": music_id, "root": str(root), "dirs": {d: (str(p), s) for d, (p, s) in dirs.items()},
726
+ "extractSec": round(time.time() - t0, 1)}
727
+
728
+
729
+ def ingest_task(music_id: int, charts: list, root: str, cfg: dict | None = None) -> list[dict]:
730
+ """Ingest the charts [(difficulty, dir, summary, files)] of one music; one result per chart."""
731
+ cfg = cfg or _W["cfg"]
732
+ site = Path(cfg["site"])
733
+ store, bgm, out = Store(site), {}, []
734
+ for d, pdir, summary, files in charts:
735
+ try:
736
+ r = ingest(store, site, music_id, d, Path(pdir), summary, files, list(FLOWS), cfg["audioFormat"],
737
+ cfg["audio"], Path(root), bgm, cfg["language"], cfg.get("prefix", ""), cfg.get("regions"),
738
+ memo=cfg.get("build"), options=cfg.get("options"))
739
+ r["scene"] = summary.get("scene")
740
+ out.append(r)
741
+ except ConfigError:
742
+ raise # a setting the whole build needs (Pipeline)
743
+ except Exception as e:
744
+ out.append(_failure(f"{music_id}_{d}", "ingest", e))
745
+ return out
746
+
747
+
748
+ def _failure(cid: str, stage: str, e: BaseException) -> dict:
749
+ cause = f"{type(e).__name__}: {e}"
750
+ _log(f"{cid}: {stage} failed: {cause[:300]}")
751
+ tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
752
+ return {"id": cid, "ok": False, "stage": stage, "error": cause[:2000], "trace": tb[-3000:]}
753
+
754
+
755
+ class Pipeline:
756
+ """Charts of many musics through extract -> read sets -> ingest, each stage on its own executor.
757
+
758
+ `extract(music, difficulties)` / `ingest(music, charts, root)` are submitted to `workers` (an Executor: the
759
+ extraction processes), `read(dir, chart id)` to `readers` (threads that start Node). `window`: musics between
760
+ the start of their extraction and the end of their ingest. `cleanup(root)` after a music's ingest. -> one result per chart.
761
+ A failure fails its charts only, except a ConfigError (a setting or tool the build needs): run raises it once
762
+ the stages in flight have ended."""
763
+
764
+ def __init__(self, workers, readers, extract, read, ingest, cleanup, window: int, log=_log):
765
+ self.workers, self.readers, self.window, self.log = workers, readers, max(1, window), log
766
+ self.extract_fn, self.read_fn, self.ingest_fn, self.cleanup = extract, read, ingest, cleanup
767
+ self.events: "queue.Queue" = queue.Queue()
768
+
769
+ def run(self, musics: list[tuple[int, list[str]]]) -> list[dict]:
770
+ todo = list(musics)
771
+ todo.reverse()
772
+ active, results, t0 = {}, [], {}
773
+
774
+ def start():
775
+ while todo and len(active) < self.window:
776
+ m, ds = todo.pop()
777
+ active[m] = {"ds": ds, "files": {}, "fails": [], "left": len(ds)}
778
+ t0[m] = time.time()
779
+ f = self.workers.submit(self.extract_fn, m, ds)
780
+ f.add_done_callback(lambda f, m=m: self.events.put(("extracted", m, f)))
781
+
782
+ start()
783
+ stop = None # the ConfigError that ends the run
784
+
785
+ def failed(e) -> None:
786
+ nonlocal stop
787
+ if isinstance(e, ConfigError):
788
+ stop = stop or e
789
+ todo.clear()
790
+
791
+ while active:
792
+ kind, m, f, *rest = self.events.get()
793
+ st = active[m]
794
+ if stop is not None and kind == "extracted" and f.exception() is None:
795
+ self.cleanup(f.result()["root"])
796
+ del active[m]
797
+ continue
798
+ if kind == "extracted":
799
+ try:
800
+ x = f.result()
801
+ except Exception as e:
802
+ failed(e)
803
+ results += [_failure(f"{m}_{d}", "extract", e) for d in st["ds"]]
804
+ del active[m]
805
+ start()
806
+ continue
807
+ st.update(x=x)
808
+ for d in st["ds"]:
809
+ g = self.readers.submit(self.read_fn, x["dirs"][d][0], f"{m}_{d}")
810
+ g.add_done_callback(lambda g, m=m, d=d: self.events.put(("read", m, g, d)))
811
+ elif kind == "read":
812
+ d = rest[0]
813
+ try:
814
+ st["files"][d] = f.result()
815
+ except Exception as e:
816
+ failed(e)
817
+ st["fails"].append(_failure(f"{m}_{d}", "read set", e))
818
+ st["left"] -= 1
819
+ if st["left"] == 0 and stop is not None:
820
+ self.cleanup(st["x"]["root"])
821
+ del active[m]
822
+ elif st["left"] == 0:
823
+ x = st["x"]
824
+ charts = [(d, *x["dirs"][d], st["files"][d]) for d in st["ds"] if d in st["files"]]
825
+ g = self.workers.submit(self.ingest_fn, m, charts, x["root"])
826
+ g.add_done_callback(lambda g, m=m: self.events.put(("ingested", m, g)))
827
+ else:
828
+ x = st["x"]
829
+ try:
830
+ rs = f.result()
831
+ except Exception as e:
832
+ failed(e)
833
+ rs = [_failure(f"{m}_{d}", "ingest", e) for d in st["files"]]
834
+ rs += st["fails"]
835
+ dt = time.time() - t0[m]
836
+ for r in rs:
837
+ r.update(music=m, extractSec=x["extractSec"], musicSec=round(dt, 1))
838
+ self.log(f"{m}: {sum(r['ok'] for r in rs)}/{len(rs)} charts, extract {x['extractSec']:.0f} s, "
839
+ f"total {dt:.0f} s")
840
+ results += rs
841
+ self.cleanup(x["root"])
842
+ del active[m]
843
+ start()
844
+ if stop is not None:
845
+ raise stop
846
+ return results
847
+
848
+
849
+ def _pool_call(fn_name: str, *args):
850
+ return globals()[fn_name](*args)
851
+
852
+
853
+ # ---------------------------------------------------------------- player
854
+ def check_player(player_dir) -> Path:
855
+ """The ournotes-player checkout or installed package: its read-set script, built bundle and page must exist."""
856
+ if player_dir is None:
857
+ raise ConfigError("the site needs the player: give --player <ournotes-player checkout or package> "
858
+ "(or `player` in the [paths] table of the config file, NNNOTES_PATHS_PLAYER)")
859
+ d = Path(player_dir)
860
+ missing = [rel for rel in (READ_SET_SCRIPT, *PLAYER_BUNDLES, f"{PLAYER_PAGE_DIR}/index.html")
861
+ if not (d / rel).is_file()]
862
+ if missing:
863
+ raise ConfigError(f"{d} is not a built ournotes-player (missing {', '.join(missing)}; "
864
+ f"a checkout needs its build first)")
865
+ return d.resolve()
866
+
867
+
868
+ READ_SETS = cache.bucket("readsets", salt=cache.source_salt(__file__), disk=True)
869
+ READ_SET_CHECK = 10 # plan mode: the build's first chart and 1 in this many others also run the full simulation
870
+ READ_SET_SERVE_TIMEOUT = 600.0 # seconds a served read set may take (ReadSetServers); then its process is ended
871
+ _player_ids: dict = {}
872
+ _features: dict = {}
873
+ _features_lock = threading.Lock()
874
+
875
+
876
+ def player_id(player_dir: Path) -> str:
877
+ """Key of the player code a read set runs: every file of its src/, scripts/ and package.json, and the Node
878
+ executable (file identity and version)."""
879
+ player_dir = Path(player_dir)
880
+ node = tool("node", "node")
881
+ k = (str(player_dir), node)
882
+ if k not in _player_ids:
883
+ files = [p for d in ("src", "scripts") for p in sorted((player_dir / d).rglob("*")) if p.is_file()]
884
+ files += [player_dir / "package.json"] if (player_dir / "package.json").is_file() else []
885
+ version = subprocess.run([node, "--version"], capture_output=True, text=True).stdout.strip()
886
+ _player_ids[k] = cache.key(cache.file_id(node), version,
887
+ [(p.relative_to(player_dir).as_posix(), p.read_bytes()) for p in files])
888
+ return _player_ids[k]
889
+
890
+
891
+ def player_features(player_dir: Path) -> frozenset:
892
+ """The read-set modes the player's READ_SET_SCRIPT offers beyond the full simulation (`--features` -> JSON
893
+ {"features": [...]}; a script without the option exits with its usage: none). Asked once per player code."""
894
+ k = player_id(player_dir)
895
+ with _features_lock:
896
+ if k not in _features:
897
+ r = subprocess.run([tool("node", "node"), str(Path(player_dir) / READ_SET_SCRIPT), "--features"],
898
+ capture_output=True, text=True, encoding="utf-8")
899
+ feats = frozenset()
900
+ if r.returncode == 0:
901
+ try:
902
+ doc = json.loads(r.stdout)
903
+ except ValueError:
904
+ doc = None
905
+ if not (isinstance(doc, dict) and isinstance(doc.get("features"), list)):
906
+ raise RuntimeError(f"read set --features: expected a JSON object with a features list, got "
907
+ f"{r.stdout[:200]!r}")
908
+ feats = frozenset(str(f) for f in doc["features"])
909
+ _features[k] = feats
910
+ return _features[k]
911
+
912
+
913
+ def live_dir_digest(live_dir: Path) -> list:
914
+ """[(path relative to live_dir, sha256)] of every file of a live directory, in path order."""
915
+ live_dir = Path(live_dir)
916
+ files = sorted(p for p in live_dir.rglob("*") if p.is_file())
917
+ return [(p.relative_to(live_dir).as_posix(), hashlib.sha256(p.read_bytes()).digest()) for p in files]
918
+
919
+
920
+ def read_set_cached(live_dir: Path, player_dir: Path, mode: str = "full", digest: list | None = None,
921
+ run=None, settings: dict | None = None) -> list[str]:
922
+ """read_set, reused when the live directory's files, the player code, Node, the mode and the settings are the
923
+ same as for a read set stored before (READ_SETS: this process, and the disk layer of the build's cache).
924
+ `digest`: the live directory's live_dir_digest when the caller has it. `run`: what reads a set that is not
925
+ stored, with read_set's arguments and answer (default read_set; ReadSetServers.read_set answers the same)."""
926
+ live_dir = Path(live_dir)
927
+ parts = [player_id(player_dir), mode, digest if digest is not None else live_dir_digest(live_dir)]
928
+ if settings:
929
+ parts.append(json.dumps(settings, sort_keys=True))
930
+ k = READ_SETS.key(*parts)
931
+ hit = READ_SETS.get(k)
932
+ if hit is not None:
933
+ return json.loads(hit)
934
+ out = (run or read_set)(live_dir, player_dir, mode, **({"settings": settings} if settings else {}))
935
+ READ_SETS.put(k, json.dumps(out).encode("utf-8"))
936
+ return out
937
+
938
+
939
+ def read_set(live_dir: Path, player_dir: Path, mode: str = "full", settings: dict | None = None) -> list[str]:
940
+ """Every file of `live_dir` the player reads for the whole chart in its default state (`settings`: with those
941
+ Live options instead, player_features has "settings"), from the player's own code run in Node
942
+ (READ_SET_SCRIPT). mode "full": the simulation (no-op WebGL2 / WebAudio, every frame from the start to the
943
+ chart's end); "plan": the player lists the same files from the chart's contents without stepping frames
944
+ (player_features has "plan"; ReadSets checks it against the full simulation)."""
945
+ if mode not in ("full", "plan"):
946
+ raise ValueError(f"read set mode {mode}")
947
+ r = subprocess.run([tool("node", "node"), str(Path(player_dir) / READ_SET_SCRIPT), str(Path(live_dir)),
948
+ *(["--plan"] if mode == "plan" else []),
949
+ *([f"--settings={json.dumps(settings, sort_keys=True)}"] if settings else [])],
950
+ capture_output=True, text=True, encoding="utf-8")
951
+ if r.returncode != 0:
952
+ raise RuntimeError(f"read set of {Path(live_dir).name} failed:\n{(r.stderr or r.stdout)[-4000:]}")
953
+ return read_set_answer(json.loads(r.stdout), mode)
954
+
955
+
956
+ def read_set_answer(out, mode: str) -> list[str]:
957
+ """The files of an answer of the read-set script (its JSON output) in `mode`, checked: a plan answered in plan
958
+ mode, a full simulation that reached the end of the chart, a list of paths."""
959
+ if mode == "plan" and not (isinstance(out, dict) and out.get("mode") == "plan"):
960
+ raise RuntimeError("read set --plan: the player did not answer in plan mode")
961
+ if isinstance(out, dict): # {files, state, frames} form
962
+ if mode == "full" and out.get("state", "ended") != "ended":
963
+ raise RuntimeError(f"read set: the chart did not end ({out.get('state')} after {out.get('frames')} frames)")
964
+ out = out.get("files")
965
+ if not isinstance(out, list) or not all(isinstance(p, str) for p in out):
966
+ raise RuntimeError("read set: expected a JSON list of paths")
967
+ return out
968
+
969
+
970
+ class ReadSetServerError(RuntimeError):
971
+ """A read-set server that exited, answered something else than an answer, or none within the timeout (hung)."""
972
+
973
+ def __init__(self, message: str, hung: bool = False):
974
+ super().__init__(message)
975
+ self.hung = hung
976
+
977
+
978
+ class ReadSetServer:
979
+ """One `node READ_SET_SCRIPT --serve` process (the player's feature "serve"): read sets one at a time, each asked
980
+ by a JSON line on its stdin and answered by one on its stdout; the last lines of its stderr are kept for errors."""
981
+
982
+ def __init__(self, player_dir: Path):
983
+ self.proc = subprocess.Popen([tool("node", "node"), str(Path(player_dir) / READ_SET_SCRIPT), "--serve"],
984
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
985
+ text=True, encoding="utf-8", errors="replace")
986
+ self.lines: queue.Queue = queue.Queue()
987
+ self.stderr: collections.deque = collections.deque(maxlen=50)
988
+ threading.Thread(target=self._pump, args=(self.proc.stdout, self.lines.put, True), daemon=True).start()
989
+ self._stderr_pump = threading.Thread(target=self._pump, args=(self.proc.stderr, self.stderr.append, False),
990
+ daemon=True)
991
+ self._stderr_pump.start()
992
+
993
+ @staticmethod
994
+ def _pump(stream, put, eof: bool) -> None:
995
+ for line in stream:
996
+ put(line)
997
+ if eof:
998
+ put(None)
999
+
1000
+ def ask(self, request: dict, timeout: float) -> dict:
1001
+ """The answer to `request`: {"ok": true, "result"} or {"ok": false, "error"}. ReadSetServerError when the
1002
+ process has exited, answers something else or nothing within `timeout` seconds."""
1003
+ try:
1004
+ self.proc.stdin.write(json.dumps(request) + "\n")
1005
+ self.proc.stdin.flush()
1006
+ except OSError:
1007
+ pass # exited: the end of its stdout follows
1008
+ try:
1009
+ line = self.lines.get(timeout=timeout)
1010
+ except queue.Empty:
1011
+ raise ReadSetServerError(f"no answer within {timeout:.0f} s", hung=True) from None
1012
+ if line is None:
1013
+ code = self.proc.wait()
1014
+ self._stderr_pump.join(timeout=2)
1015
+ raise ReadSetServerError(f"the read-set server exited ({code}):\n"
1016
+ f"{''.join(self.stderr)[-4000:]}")
1017
+ try:
1018
+ doc = json.loads(line)
1019
+ except ValueError:
1020
+ doc = None
1021
+ if not isinstance(doc, dict) or not isinstance(doc.get("ok"), bool):
1022
+ raise ReadSetServerError(f"the read-set server answered {line[:200]!r}")
1023
+ return doc
1024
+
1025
+ def close(self, wait: float = 10.0) -> None:
1026
+ """End of its stdin: the server exits (ended after `wait` seconds if it does not)."""
1027
+ try:
1028
+ self.proc.stdin.close()
1029
+ except OSError:
1030
+ pass
1031
+ try:
1032
+ self.proc.wait(timeout=wait)
1033
+ except subprocess.TimeoutExpired:
1034
+ self.kill()
1035
+
1036
+ def kill(self) -> None:
1037
+ self.proc.kill()
1038
+ self.proc.wait()
1039
+ try:
1040
+ self.proc.stdin.close()
1041
+ except OSError:
1042
+ pass
1043
+
1044
+
1045
+ class ReadSetServers:
1046
+ """Read-set servers for the reads of a build: a read takes an idle server or starts one (so there are at most as
1047
+ many as reads run at a time: one per read-set slot), and gives it back for the build's later charts; close()
1048
+ ends them. `read_set` has read_set's arguments and answers as read_set does. A server that exits or answers
1049
+ something else is replaced and the chart asked once more; a chart it answers no read set for within `timeout`
1050
+ seconds fails (RuntimeError, as a failed read_set) and the server is ended."""
1051
+
1052
+ def __init__(self, player_dir: Path, timeout: float = READ_SET_SERVE_TIMEOUT):
1053
+ self.player_dir, self.timeout = Path(player_dir), timeout
1054
+ self.lock = threading.Lock()
1055
+ self.idle: list[ReadSetServer] = []
1056
+ self.started = 0
1057
+ self.closed = False
1058
+
1059
+ def _take(self) -> ReadSetServer:
1060
+ with self.lock:
1061
+ if self.closed:
1062
+ raise RuntimeError("read set: the build's read-set servers are closed")
1063
+ while self.idle:
1064
+ s = self.idle.pop()
1065
+ if s.proc.poll() is None:
1066
+ return s
1067
+ s.kill() # exited while idle
1068
+ self.started += 1
1069
+ return ReadSetServer(self.player_dir)
1070
+
1071
+ def _give(self, s: ReadSetServer) -> None:
1072
+ with self.lock:
1073
+ if not self.closed:
1074
+ self.idle.append(s)
1075
+ return
1076
+ s.close()
1077
+
1078
+ def read_set(self, live_dir: Path, player_dir: Path, mode: str = "full", settings: dict | None = None) -> list[str]:
1079
+ if mode not in ("full", "plan"):
1080
+ raise ValueError(f"read set mode {mode}")
1081
+ name = Path(live_dir).name
1082
+ request = {"chart": str(Path(live_dir)), "plan": mode == "plan", **({"settings": settings} if settings else {})}
1083
+ for attempt in (1, 2):
1084
+ s = self._take()
1085
+ try:
1086
+ doc = s.ask(request, self.timeout)
1087
+ except ReadSetServerError as e:
1088
+ s.kill()
1089
+ if e.hung or attempt == 2:
1090
+ raise RuntimeError(f"read set of {name} failed: {e}") from None
1091
+ continue
1092
+ self._give(s)
1093
+ if not doc["ok"]:
1094
+ raise RuntimeError(f"read set of {name} failed:\n{str(doc.get('error'))[-4000:]}")
1095
+ return read_set_answer(doc.get("result"), mode)
1096
+
1097
+ def close(self) -> None:
1098
+ with self.lock:
1099
+ self.closed, idle, self.idle = True, self.idle, []
1100
+ for s in idle:
1101
+ s.close()
1102
+
1103
+
1104
+ class ReadSetMismatch(RuntimeError):
1105
+ """The player's plan and its full simulation read different files for a chart."""
1106
+
1107
+
1108
+ class ReadSets:
1109
+ """The read sets of a build. With a player whose read-set script has the plan mode, each chart's set comes
1110
+ from the plan; the build's first chart (expect: the first in build order, whenever its read set comes) and 1 in
1111
+ `check_every` others (chosen by the player code and chart id) also run the full simulation, and the two must be
1112
+ the same set. Which charts are checked depends on the inputs only, not on which extraction ends first, so the
1113
+ same build reads and caches the same sets. A difference fails that chart (ReadSetMismatch, with both
1114
+ differences), the full simulation reads every later chart, and the charts read from an unchecked plan until then
1115
+ are listed. A chart the plan cannot list (the script fails) is read by the full simulation, logged and listed in
1116
+ the summary (planErrors). A player without the plan mode: the full simulation for every chart. The player is
1117
+ asked for its modes when the first chart needs a read set. With a player that also has the serve mode, the plans
1118
+ are read by long-lived processes (ReadSetServers, one per read-set slot) instead of one process per chart; the
1119
+ full simulation always runs in a process of its own. close() ends the servers. `summary()` goes into the build
1120
+ result. `read(..., variants)`: the union with the chart's read sets under each of the Live option settings
1121
+ `variants` (the player's feature "settings"; plans in plan mode, a variant whose plan fails read by the full
1122
+ simulation), not checked by the full simulation."""
1123
+
1124
+ def __init__(self, player_dir: Path, log=_log, check_every: int = READ_SET_CHECK):
1125
+ self.player_dir = Path(player_dir)
1126
+ self.initial = self.mode = None
1127
+ self.check_every = max(1, check_every)
1128
+ self.log = log
1129
+ self.lock = threading.Lock() # counters, mode
1130
+ self.first: str | None = None # the chart checked as the build's first (expect)
1131
+ self.counts = {"plan": 0, "full": 0, "checked": 0}
1132
+ self.variants = 0 # read sets of option variants (read's `variants`)
1133
+ self.features: frozenset = frozenset()
1134
+ self.unchecked: list[str] = []
1135
+ self.mismatches: list[dict] = []
1136
+ self.plan_errors: list[dict] = []
1137
+ self.servers: ReadSetServers | None = None # the plans' servers (the player's serve mode)
1138
+
1139
+ def start(self) -> str:
1140
+ """The mode (player_features), asked once."""
1141
+ with self.lock:
1142
+ if self.initial is None:
1143
+ features = self.features = player_features(self.player_dir)
1144
+ self.initial = self.mode = "plan" if "plan" in features else "full"
1145
+ if self.initial == "plan" and "serve" in features:
1146
+ self.servers = ReadSetServers(self.player_dir)
1147
+ return self.mode
1148
+
1149
+ def close(self) -> None:
1150
+ """Ends the plans' servers; a later plan runs in a process of its own."""
1151
+ with self.lock:
1152
+ servers, self.servers = self.servers, None
1153
+ if servers is not None:
1154
+ servers.close()
1155
+
1156
+ def expect(self, chart_ids) -> None:
1157
+ """The charts a build (group) reads, in build order; the first of the first call is the chart checked as the
1158
+ build's first."""
1159
+ with self.lock:
1160
+ if self.first is None and chart_ids:
1161
+ self.first = chart_ids[0]
1162
+
1163
+ def describe(self) -> str:
1164
+ if self.start() == "full":
1165
+ return "read sets: the full simulation of every chart (the player has no plan mode)"
1166
+ return (f"read sets: the player's plan{' (served)' if self.servers is not None else ''}; "
1167
+ f"{self.first or 'no first chart'} and 1 in {self.check_every} other "
1168
+ f"charts checked by the full simulation")
1169
+
1170
+ def need_settings(self) -> None:
1171
+ """ConfigError unless the player's read-set script reads with given settings (feature "settings")."""
1172
+ self.start()
1173
+ if "settings" not in self.features:
1174
+ raise ConfigError(f"{self.player_dir}: its {READ_SET_SCRIPT} has no settings (feature \"settings\"), "
1175
+ f"which the read sets of live options need")
1176
+
1177
+ def sampled(self, chart_id: str) -> bool:
1178
+ h = hashlib.sha256(f"{player_id(self.player_dir)}|{chart_id}".encode("utf-8")).digest()
1179
+ return int.from_bytes(h[:4], "big") % self.check_every == 0
1180
+
1181
+ def read(self, live_dir, chart_id: str, variants=()) -> list[str]:
1182
+ live_dir = Path(live_dir)
1183
+ self.start()
1184
+ digest = live_dir_digest(live_dir)
1185
+ files = self._default(live_dir, chart_id, digest)
1186
+ if not variants:
1187
+ return files
1188
+ have = set(files)
1189
+ more: set[str] = set()
1190
+ for settings in variants:
1191
+ more.update(p for p in self._variant(live_dir, chart_id, digest, settings) if p not in have)
1192
+ with self.lock:
1193
+ self.variants += len(variants)
1194
+ return files + sorted(more)
1195
+
1196
+ def _variant(self, live_dir: Path, chart_id: str, digest: list, settings: dict) -> list[str]:
1197
+ """The read set with `settings`: the plan in plan mode (the full simulation when the plan fails)."""
1198
+ if self.mode == "plan":
1199
+ servers = self.servers
1200
+ try:
1201
+ return read_set_cached(live_dir, self.player_dir, "plan", digest,
1202
+ run=servers.read_set if servers is not None else None, settings=settings)
1203
+ except RuntimeError as e:
1204
+ with self.lock:
1205
+ self.plan_errors.append({"id": chart_id, "settings": settings, "error": str(e)[-500:]})
1206
+ self.log(f"{chart_id}: the player's read-set plan with {json.dumps(settings, sort_keys=True)} "
1207
+ f"failed; the full simulation reads it")
1208
+ return read_set_cached(live_dir, self.player_dir, "full", digest, settings=settings)
1209
+
1210
+ def _default(self, live_dir: Path, chart_id: str, digest: list) -> list[str]:
1211
+ if self.mode == "plan":
1212
+ if chart_id == self.first or self.sampled(chart_id):
1213
+ return self._checked(live_dir, chart_id, digest)
1214
+ files = self._plan(live_dir, chart_id, digest)
1215
+ if files is not None:
1216
+ with self.lock:
1217
+ self.counts["plan"] += 1
1218
+ self.unchecked.append(chart_id)
1219
+ return files
1220
+ files = read_set_cached(live_dir, self.player_dir, "full", digest)
1221
+ with self.lock:
1222
+ self.counts["full"] += 1
1223
+ return files
1224
+
1225
+ def _plan(self, live_dir: Path, chart_id: str, digest: list) -> list[str] | None:
1226
+ """The plan's read set, None when the player's script failed on the chart (logged, listed)."""
1227
+ servers = self.servers
1228
+ try:
1229
+ return read_set_cached(live_dir, self.player_dir, "plan", digest,
1230
+ run=servers.read_set if servers is not None else None)
1231
+ except RuntimeError as e:
1232
+ with self.lock:
1233
+ self.plan_errors.append({"id": chart_id, "error": str(e)[-500:]})
1234
+ self.log(f"{chart_id}: the player's read-set plan failed; the full simulation reads it "
1235
+ f"({str(e).strip().splitlines()[-1][:200] if str(e).strip() else type(e).__name__})")
1236
+ return None
1237
+
1238
+ def _checked(self, live_dir: Path, chart_id: str, digest: list) -> list[str]:
1239
+ plan = self._plan(live_dir, chart_id, digest)
1240
+ full = read_set_cached(live_dir, self.player_dir, "full", digest)
1241
+ if plan is None:
1242
+ with self.lock:
1243
+ self.counts["full"] += 1
1244
+ return full
1245
+ missing, extra = sorted(set(full) - set(plan)), sorted(set(plan) - set(full))
1246
+ with self.lock:
1247
+ self.counts["checked"] += 1
1248
+ if not missing and not extra:
1249
+ return full
1250
+ self.mismatches.append({"id": chart_id, "missing": missing, "extra": extra})
1251
+ if self.mode == "plan":
1252
+ self.mode = "full"
1253
+ self.log(f"{chart_id}: the player's read-set plan differs from its full simulation "
1254
+ f"({len(missing)} missing, {len(extra)} extra); the full simulation reads the other charts")
1255
+ raise ReadSetMismatch(f"the player's plan and its full simulation differ: missing {missing[:10]}"
1256
+ f"{' ...' if len(missing) > 10 else ''}, extra {extra[:10]}"
1257
+ f"{' ...' if len(extra) > 10 else ''}")
1258
+
1259
+ def summary(self) -> dict:
1260
+ """{mode, plan, full, checked, variants?, mismatches, planErrors, unchecked}: charts read by each mode
1261
+ (checked: by both), read sets of option variants (when there were any); after a mismatch the charts whose
1262
+ set came from an unchecked plan are listed, else counted."""
1263
+ with self.lock:
1264
+ return {"mode": self.initial, **self.counts, **({"variants": self.variants} if self.variants else {}),
1265
+ "mismatches": list(self.mismatches), "planErrors": list(self.plan_errors),
1266
+ "unchecked": sorted(self.unchecked) if self.mismatches else len(self.unchecked)}
1267
+
1268
+
1269
+ def player_bundles(player_dir: Path, rels) -> tuple[dict[str, bytes], str]:
1270
+ """The bundles `rels` of the player and their source maps ({file name: bytes}), and the bundles' short hash."""
1271
+ bundles = {}
1272
+ for rel in rels:
1273
+ bundles[Path(rel).name] = (player_dir / rel).read_bytes()
1274
+ if (player_dir / f"{rel}.map").is_file():
1275
+ bundles[Path(rel).name + ".map"] = (player_dir / f"{rel}.map").read_bytes()
1276
+ return bundles, hashlib.sha256(b"".join(bundles[Path(rel).name] for rel in rels)).hexdigest()[:16]
1277
+
1278
+
1279
+ def page_files(page_dir: Path, imports: dict[str, str], version: str,
1280
+ names: str = "PAGE_IMPORTS") -> dict[str, bytes]:
1281
+ """The files of a player page directory; in its .html / .js files the module specifiers `imports` (`names`)
1282
+ point at the bundles copied next to the page and PLAYER_VERSION_MARKER is `version`."""
1283
+ page = {f.relative_to(page_dir).as_posix(): f.read_bytes() for f in sorted(page_dir.rglob("*")) if f.is_file()}
1284
+ for rel, data in page.items():
1285
+ if Path(rel).suffix not in (".html", ".js", ".mjs"):
1286
+ continue
1287
+ t = data.decode("utf-8")
1288
+ for src, dst in imports.items():
1289
+ for q in ('"', "'"):
1290
+ t = t.replace(f"{q}{src}{q}", f"{q}{dst}{q}")
1291
+ if re.search(r"""["'](?:\.\./)+src/""", t):
1292
+ raise RuntimeError(f"player page {rel} imports the player's sources beyond {names}")
1293
+ page[rel] = t.replace(PLAYER_VERSION_MARKER, version).encode("utf-8")
1294
+ return page
1295
+
1296
+
1297
+ def write_player(site: Path, player_dir: Path, stories: bool = False) -> dict:
1298
+ """The player's page (every file of PLAYER_PAGE_DIR; in its .html / .js files the PAGE_IMPORTS specifiers point
1299
+ at the bundle and PLAYER_VERSION_MARKER is the bundles' short hash) and bundles (PLAYER_BUNDLES and their source
1300
+ maps) into the site root; when the player has the Live2D page, the same for LIVE2D_PAGE_DIR, LIVE2D_PAGE_IMPORTS
1301
+ and LIVE2D_BUNDLES into LIVE2D_PAGE_SITE_DIR; when it has the story page and its bundles, the same for
1302
+ STORY_PAGE_DIR, STORY_PAGE_IMPORTS and STORY_BUNDLES into STORY_PAGE_SITE_DIR (`stories`: the build adds stories,
1303
+ so a story page without its bundles is a ConfigError instead of being left out)."""
1304
+ player_dir = check_player(player_dir)
1305
+ bundles, version = player_bundles(player_dir, PLAYER_BUNDLES)
1306
+ page = page_files(player_dir / PLAYER_PAGE_DIR, PAGE_IMPORTS, version)
1307
+ files = {**page, **bundles}
1308
+ live2d = {}
1309
+ if (player_dir / LIVE2D_PAGE_DIR / "index.html").is_file():
1310
+ missing = [rel for rel in LIVE2D_BUNDLES if not (player_dir / rel).is_file()]
1311
+ if missing:
1312
+ raise ConfigError(f"{player_dir} has the Live2D page but not {', '.join(missing)} "
1313
+ f"(a checkout needs its build first)")
1314
+ live2d_bundles, live2d_version = player_bundles(player_dir, LIVE2D_BUNDLES)
1315
+ live2d = page_files(player_dir / LIVE2D_PAGE_DIR, LIVE2D_PAGE_IMPORTS, live2d_version, "LIVE2D_PAGE_IMPORTS")
1316
+ files.update({f"{LIVE2D_PAGE_SITE_DIR}/{r}": d for r, d in {**live2d, **live2d_bundles}.items()})
1317
+ story_page = {}
1318
+ if (player_dir / STORY_PAGE_DIR / "index.html").is_file():
1319
+ missing = [rel for rel in STORY_BUNDLES if not (player_dir / rel).is_file()]
1320
+ if missing and stories:
1321
+ raise ConfigError(f"{player_dir} has the story page but not {', '.join(missing)} "
1322
+ f"(a checkout needs its build first)")
1323
+ if not missing:
1324
+ story_bundles, story_version = player_bundles(player_dir, STORY_BUNDLES)
1325
+ story_page = page_files(player_dir / STORY_PAGE_DIR, STORY_PAGE_IMPORTS, story_version,
1326
+ "STORY_PAGE_IMPORTS")
1327
+ files.update({f"{STORY_PAGE_SITE_DIR}/{r}": d for r, d in {**story_page, **story_bundles}.items()})
1328
+ for rel, data in files.items():
1329
+ if rel.split("/", 1)[0] in SITE_DATA:
1330
+ raise RuntimeError(f"player page file {rel} would overwrite the site data")
1331
+ dst = Path(site) / rel
1332
+ dst.parent.mkdir(parents=True, exist_ok=True)
1333
+ dst.write_bytes(data)
1334
+ out = {"playerVersion": version, "playerBytes": sum(map(len, bundles.values())), "pageFiles": len(page),
1335
+ "live2dPageFiles": len(live2d)}
1336
+ if story_page:
1337
+ out["storyPageFiles"] = len(story_page)
1338
+ return out
1339
+
1340
+
1341
+ def entry_text(site: Path, e: dict) -> bytes:
1342
+ """The file text of a manifest entry (a split JSON file rejoined)."""
1343
+ if "parts" in e:
1344
+ return join_parts([(k, (site / a).read_bytes()) for k, a, _ in e["parts"]])
1345
+ return (site / e["asset"]).read_bytes()
1346
+
1347
+
1348
+ def reingest_json(site: Path) -> dict:
1349
+ """Every JSON file of every chart and model stored again through text_asset (after a change of the stored-text
1350
+ rules); the manifests follow, unreferenced assets go with write_index."""
1351
+ store, changed, memo = Store(site), 0, {}
1352
+ for mf in [*chart_manifests(site), *sorted((site / MODELS_DIR).glob("*.json"))]:
1353
+ man = json.loads(mf.read_text(encoding="utf-8"))
1354
+ files = man["files"]
1355
+ for p, e in files.items():
1356
+ if not p.endswith(".json"):
1357
+ continue
1358
+ key = (p, json.dumps(e, sort_keys=True)) # charts share most files: each stored once
1359
+ if key not in memo:
1360
+ memo[key] = store.put_file(p, text_asset(p, entry_text(site, e).decode("utf-8")))
1361
+ ne = memo[key]
1362
+ if ne != e:
1363
+ files[p] = ne
1364
+ changed += 1
1365
+ mf.write_bytes(_dump(man))
1366
+ return {"changedEntries": changed}
1367
+
1368
+
1369
+ def index_meta(site: Path, charts: list[dict], language: str | None = None,
1370
+ regions: list[dict] | None = None) -> dict:
1371
+ """The top-level keys of charts.json besides `charts`: `language` (the default listing language), `languages`
1372
+ (the languages of the entries' `titles`) and `regions` (id, name, languages; merged by id into those of the
1373
+ existing charts.json, first the ones it has); keys without a value are left out."""
1374
+ old = {}
1375
+ f = Path(site) / CHARTS_INDEX
1376
+ if f.is_file():
1377
+ try:
1378
+ old = json.loads(f.read_text(encoding="utf-8"))
1379
+ except ValueError:
1380
+ old = {}
1381
+ new = {r["id"]: r for r in regions or []}
1382
+ recs = [new.pop(r["id"], r) for r in old.get("regions") or [] if isinstance(r, dict) and "id" in r]
1383
+ recs += list(new.values())
1384
+ known = {c for e in charts for c in (e.get("titles") or {})}
1385
+ langs = [c for c in languages.LANGUAGES if c in known] + sorted(known - set(languages.LANGUAGES))
1386
+ meta = {"language": language or old.get("language"), "languages": langs, "regions": recs}
1387
+ return {k: v for k, v in meta.items() if v}
1388
+
1389
+
1390
+ def write_index(site: Path, language: str | None = None, regions: list[dict] | None = None) -> dict:
1391
+ """site/charts.json from every chart manifest present (index_meta: `language`, `regions` of this build),
1392
+ site/models.json from every model manifest (webmodel.write_models_index) and site/stories.json from every story
1393
+ manifest (storysite.write_stories_index; its `language` and `regions` are those storysite.build wrote); assets
1394
+ no chart, no model and no story (common files and every language group) references removed."""
1395
+ from .storysite import write_stories_index
1396
+ from .webmodel import write_models_index
1397
+ order = {d: i for i, d in enumerate(DIFFICULTIES)}
1398
+ charts, used = [], set()
1399
+ (site / "assets").mkdir(parents=True, exist_ok=True)
1400
+ for p in chart_manifests(site):
1401
+ man = json.loads(p.read_text(encoding="utf-8"))
1402
+ for e in man["files"].values():
1403
+ used.update(entry_assets(e))
1404
+ entry = {"id": p.stem, "manifest": p.relative_to(site).as_posix(), "musicId": man["musicId"],
1405
+ "difficulty": man["difficulty"], "audio": man["audio"], "audioFormat": man.get("audioFormat"),
1406
+ "flows": man.get("flows"), "bytes": sum(f["size"] for f in man["files"].values()), **man["chart"]}
1407
+ if man.get("regions"):
1408
+ entry["regions"] = man["regions"]
1409
+ charts.append(entry)
1410
+ charts.sort(key=lambda c: (c["musicId"], order[c["difficulty"]], c["manifest"]))
1411
+ meta = index_meta(site, charts, language, regions)
1412
+ (site / CHARTS_INDEX).write_bytes(_dump({"format": SITE_FORMAT, **meta, "charts": charts}))
1413
+ models, model_assets = write_models_index(site)
1414
+ used |= model_assets
1415
+ stories, story_assets = write_stories_index(site)
1416
+ used |= story_assets
1417
+ removed = 0
1418
+ for f in (site / "assets").iterdir():
1419
+ if f"assets/{f.name}" not in used:
1420
+ f.unlink()
1421
+ removed += 1
1422
+ sizes = [f.stat().st_size for f in (site / "assets").iterdir()]
1423
+ out = {"charts": len(charts), "models": models, "assets": len(sizes), "assetBytes": sum(sizes),
1424
+ "removedAssets": removed}
1425
+ if stories:
1426
+ out["stories"] = stories
1427
+ return out
1428
+
1429
+
1430
+ # ---------------------------------------------------------------- build
1431
+ def pipeline_window(workers: int, read_workers: int) -> int:
1432
+ """Musics in flight in a build's Pipeline: one per extraction process, plus enough musics waiting for their read
1433
+ sets to keep every read-set slot busy (4 charts a music), at least 2 (each in-flight music keeps its live
1434
+ directories on disk until its ingest)."""
1435
+ return workers + max(2, -(-read_workers // 4))
1436
+
1437
+
1438
+ def _group_pairs(pairs, master: Path) -> list[tuple[int, str]]:
1439
+ """The charts of a region group: `pairs` (None: every chart) that its master data has, in the given order."""
1440
+ have = all_pairs(master)
1441
+ if pairs is None:
1442
+ return have
1443
+ have = set(have)
1444
+ return [p for p in pairs if p in have]
1445
+
1446
+
1447
+ def _build_group(site: Path, tmp_root: Path, pairs, cfg: Config, region: str, prefix: str, regions: list[str],
1448
+ job: dict, force: bool, workers: int | None, log, read_workers: int | None = None,
1449
+ reads: "ReadSets | None" = None) -> tuple[list, list, int]:
1450
+ """The charts `pairs` of one region group into charts/<prefix><id>.json (data of `region`); manifests that exist
1451
+ are skipped unless `force` and gain the group's `regions`. -> (results, skipped ids, workers)."""
1452
+ by_music: dict[int, list[str]] = {}
1453
+ skipped = []
1454
+ for music_id, difficulty in pairs:
1455
+ cid = f"{music_id}_{difficulty}"
1456
+ if manifest_file(site, cid, prefix).exists() and not force:
1457
+ add_regions(manifest_file(site, cid, prefix), regions)
1458
+ skipped.append(prefix + cid)
1459
+ continue
1460
+ ds = by_music.setdefault(music_id, [])
1461
+ if difficulty not in ds:
1462
+ ds.append(difficulty)
1463
+ for ds in by_music.values():
1464
+ ds.sort(key=DIFFICULTIES.index)
1465
+ cpus = usable_cpus()
1466
+ if workers is None:
1467
+ workers = max(1, min(8, len(by_music), cpus // 4))
1468
+ if read_workers is None:
1469
+ read_workers = max(1, min(16, 4 * len(by_music), cpus // 2))
1470
+ results = []
1471
+ if not by_music:
1472
+ return results, skipped, workers
1473
+ gdir = Path(tempfile.mkdtemp(prefix="global-", dir=tmp_root))
1474
+ try:
1475
+ cat, master, player = open_data(cfg, region)
1476
+ options = liveoptions.resolve(job.get("liveOptions"), master)
1477
+ livenotes.extract(cat, player, gdir, master=master, options=options)
1478
+ job = {**job, "livenotes": str(gdir / "livenotes"), "scenes": str(gdir / "scenes"), "region": region,
1479
+ "prefix": prefix, "regions": regions, "options": options}
1480
+ variants = options.read_variants()
1481
+ bands: dict[int, int] = {} # band -> a music of it (the scene is built once per band)
1482
+ for m in sorted(by_music):
1483
+ try:
1484
+ bands.setdefault(live.resolve_band(cat, master, m, band=job["band"],
1485
+ leader_card=job["leaderCard"])["band"], m)
1486
+ except Exception:
1487
+ pass # the music's extraction reports it
1488
+ log(f"{sum(len(ds) for ds in by_music.values())} charts of {len(by_music)} musics"
1489
+ f"{f' for {prefix[:-1]}' if prefix else ''}, {len(bands)} band scene(s); {workers} extraction "
1490
+ f"process(es) ({cri.default_workers()} stream decodes each), {read_workers} read set(s) at a time, "
1491
+ f"{pipeline_window(workers, read_workers)} musics in flight; caches "
1492
+ f"{cache.settings()['memory'] >> 20} MB per bucket, disk "
1493
+ f"{job.get('cache') or 'none'}")
1494
+ own_reads = reads is None
1495
+ reads = reads or ReadSets(Path(job["player"]), log)
1496
+ reads.expect([f"{m}_{d}" for m, ds in sorted(by_music.items()) for d in ds]) # the Pipeline's order
1497
+ log(reads.describe())
1498
+ if variants:
1499
+ reads.need_settings()
1500
+ log(f"live options: the read set of each chart adds {len(variants)} option variant(s)")
1501
+
1502
+ if workers <= 1:
1503
+ data = (cat, master, player)
1504
+ _W.update(cfg=job, scenes={})
1505
+ ex = ThreadPoolExecutor(1) # UnityPy stays on one thread
1506
+ call = {"band": lambda b, m: band_task(b, m, job, data),
1507
+ "extract": lambda m, ds: extract_task(m, ds, job, data),
1508
+ "ingest": lambda m, charts, root: ingest_task(m, charts, root, job)}
1509
+ submit = lambda name, *a: ex.submit(call[name], *a)
1510
+ ctx = None
1511
+ else:
1512
+ ctx = mp.get_context("spawn")
1513
+ mgr = ctx.Manager()
1514
+ ex = ProcessPoolExecutor(workers, mp_context=ctx, initializer=_worker_init,
1515
+ initargs=(cfg, mgr.Lock(), job))
1516
+ submit = lambda name, *a: ex.submit(_pool_call, f"{name}_task", *a)
1517
+ try:
1518
+ for b, f in [(b, submit("band", b, m)) for b, m in sorted(bands.items())]:
1519
+ try:
1520
+ f.result()
1521
+ except ConfigError:
1522
+ raise
1523
+ except Exception as e: # its musics export the scene whole
1524
+ log(f"band {b}: shared scene failed ({type(e).__name__}: {str(e)[:200]}); exported per music")
1525
+ shutil.rmtree(gdir / "scenes" / str(b), ignore_errors=True)
1526
+ with ThreadPoolExecutor(read_workers) as readers:
1527
+ shim = SimpleNamespace(submit=lambda fn, *a: submit({extract_task: "extract",
1528
+ ingest_task: "ingest"}[fn], *a))
1529
+ def read(live_dir, chart_id):
1530
+ return reads.read(live_dir, chart_id, variants)
1531
+ results = Pipeline(shim, readers, extract_task, read, ingest_task,
1532
+ lambda root: shutil.rmtree(root, ignore_errors=True),
1533
+ window=pipeline_window(workers, read_workers), log=log
1534
+ ).run(sorted(by_music.items()))
1535
+ finally:
1536
+ ex.shutdown()
1537
+ if ctx is not None:
1538
+ mgr.shutdown()
1539
+ if own_reads:
1540
+ reads.close()
1541
+ finally:
1542
+ shutil.rmtree(gdir, ignore_errors=True)
1543
+ for r in results:
1544
+ if prefix:
1545
+ r["id"] = prefix + r["id"]
1546
+ return results, skipped, workers
1547
+
1548
+
1549
+ def build(out_dir, pairs, cfg: Config, player_dir: Path, audio_format: str = DEFAULT_AUDIO_FORMAT,
1550
+ audio: bool = True, force: bool = False, *, tmp_dir=None, log=None, workers: int | None = None,
1551
+ band: int | None = None, leader_card: int | None = None, regions: list[str] | None = None,
1552
+ fonts: str = "open", read_workers: int | None = None, live_options: dict | None = None) -> dict:
1553
+ """Add the charts `pairs` ([(musicId, difficulty)]; None: every chart of every region's master data) to the site
1554
+ at `out_dir`, with the player of the ournotes-player checkout or package at `player_dir`. The data comes from the
1555
+ settings `cfg` (each worker process opens its own). `regions`: the regions the charts serve (default: the one
1556
+ [catalog] region), grouped by chart inputs (module docstring); the first region's group writes charts/<id>.json,
1557
+ the others charts/<region>/<id>.json unless their files equal the shared manifest's. `workers`: parallel music
1558
+ processes (default a quarter of the CPUs, up to 8); `read_workers`: read sets at a time (default half the CPUs,
1559
+ up to 16). `band` / `leader_card`: the band of every chart's stage, as for live.build (default: the band of the
1560
+ music's first vocal character); `fonts`: the start canvas fonts (liveui.extract, with WEB_LIVEUI);
1561
+ `live_options`: the Live option variants the charts offer (a liveoptions.parse_specs request; checked against
1562
+ every region's master data before anything is built, liveoptions.OptionSpecError)."""
1563
+ player_dir = check_player(player_dir)
1564
+ if audio_format not in WEB_AUDIO:
1565
+ raise ValueError(f"audio format {audio_format}: one of {', '.join(WEB_AUDIO)}")
1566
+ if pairs is not None:
1567
+ pairs = list(dict.fromkeys((int(m), d) for m, d in pairs))
1568
+ bad = [d for _, d in pairs if d not in DIFFICULTIES]
1569
+ if bad:
1570
+ raise ValueError(f"difficulty {bad[0]}")
1571
+ regions = site_regions(cfg, regions)
1572
+ language = languages.check(cfg.require("catalog", "language"))
1573
+ masters = region_masters(cfg, regions)
1574
+ for m in masters.values():
1575
+ liveoptions.resolve(live_options, m)
1576
+ groups = region_groups(masters)
1577
+ site = Path(out_dir).resolve()
1578
+ (site / "charts").mkdir(parents=True, exist_ok=True)
1579
+ Store(site)
1580
+ tmp_root = Path(tmp_dir).resolve() if tmp_dir else site.parent / f"{site.name}.tmp"
1581
+ tmp_root.mkdir(parents=True, exist_ok=True)
1582
+ log = log or _log
1583
+ t0 = time.time()
1584
+ job = {"site": str(site), "tmp": str(tmp_root), "audioFormat": audio_format, "audio": bool(audio),
1585
+ "player": str(player_dir), "language": language, "fonts": fonts, "band": band, "leaderCard": leader_card,
1586
+ "cache": str(tmp_root / "cache"), "build": uuid.uuid4().hex, "liveOptions": live_options or None}
1587
+ configure_caches(job)
1588
+ results, skipped, folded, used_workers = [], [], [], 1
1589
+ offered = set()
1590
+ reads = ReadSets(player_dir, log)
1591
+ try:
1592
+ for gi, group in enumerate(groups):
1593
+ rep = group[0]
1594
+ prefix = "" if gi == 0 else f"{rep}/"
1595
+ gpairs = _group_pairs(pairs, masters[rep])
1596
+ offered.update(gpairs)
1597
+ rs, sk, w = _build_group(site, tmp_root, gpairs, cfg, rep, prefix, group, job, force, workers, log,
1598
+ read_workers, reads)
1599
+ results += rs
1600
+ skipped += sk
1601
+ used_workers = max(used_workers, w)
1602
+ if prefix:
1603
+ folded += [prefix + f"{m}_{d}" for m, d in gpairs if fold_variant(site, f"{m}_{d}", prefix)]
1604
+ finally:
1605
+ reads.close()
1606
+ for m, d in pairs or []:
1607
+ if (m, d) not in offered:
1608
+ results.append({"id": f"{m}_{d}", "ok": False, "stage": "master",
1609
+ "error": f"no MasterLiveMusicScore row for {m}_{d} in the master data of "
1610
+ f"{', '.join(regions)}"})
1611
+ v = write_player(site, player_dir)
1612
+ idx = write_index(site, language, region_meta(cfg, regions))
1613
+ failed = [r for r in results if not r["ok"]]
1614
+ scenes: dict[str, int] = {} # how the charts' scenes were made (band part / whole)
1615
+ for r in results:
1616
+ if r["ok"]:
1617
+ scenes[r.get("scene") or "?"] = scenes.get(r.get("scene") or "?", 0) + 1
1618
+ if failed:
1619
+ (site.parent / f"{site.name}.failures.json").write_bytes(_dump(sorted(failed, key=lambda r: r["id"])))
1620
+ return {"site": str(site), "built": [{k: r[k] for k in ("id", "files", "bytes", "flows")} for r in results if r["ok"]],
1621
+ "failed": [{k: r.get(k) for k in ("id", "stage", "error")} for r in failed], "skipped": skipped,
1622
+ "regions": regions, "regionGroups": groups, "foldedRegionManifests": folded, "scenes": scenes,
1623
+ "readSets": reads.summary(),
1624
+ "seconds": round(time.time() - t0, 1), "workers": used_workers, **v, **idx}