Backend: per-scan power/level/gold time-series for >=1 Qnt accounts
Browse filesCollect a precise (second-granular) time-series point every time an
account with >= 1 Qnt power (1e18) is scanned via the normal lookup /
leaderboard ingest, so the player-lookup tab can later draw power/level/
gold over time. Stored in a SEPARATE persistent store (/data/power_series
.json, own lock + own 120s save interval) so the 600 MB leaderboard
write path is untouched.
- Point format: [t_sec, level, power, gold, sword_type, shield_type].
Active sword/shield captured as the small game type-code (translatable
back via SWORD_TYPE_NAMES / SHIELD_TYPE_NAMES); -1 when unknown.
- Add sword_type/shield_type to the live lookup result.
- Throttle to <=1 point/min/account (SM_POWER_SERIES_MIN_GAP) and cap
per account (SM_POWER_SERIES_MAX_POINTS=20000); env-tunable.
- GET /power-series?username=|player_id= returns the series (or
under_1qnt=true) for the frontend.
Accounts under 1 Qnt store nothing (frontend shows "unter 1qnt").
Frontend graph to follow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -141,6 +141,28 @@ SM_LEADERBOARD_QUERY_LIMIT = max(25, int(_os.getenv("SM_LEADERBOARD_QUERY_LIMIT"
|
|
| 141 |
# und die Anzahl blieb bei 500k haengen. Jetzt 2 Mio (Headroom) + Eviction nach Power (unten).
|
| 142 |
SM_LEADERBOARD_MAX_ROWS = max(1000, int(_os.getenv("SM_LEADERBOARD_MAX_ROWS", "2000000")))
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
def _env_truthy(name: str, default: bool = False) -> bool:
|
| 145 |
value = _os.getenv(name)
|
| 146 |
if value is None:
|
|
@@ -8206,6 +8228,151 @@ def _sm_lb_publish_rows(rows):
|
|
| 8206 |
pass
|
| 8207 |
|
| 8208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8209 |
def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save: bool = True):
|
| 8210 |
updated = 0
|
| 8211 |
changed = []
|
|
@@ -8236,6 +8403,8 @@ def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save:
|
|
| 8236 |
state["updated_at"] = int(time.time() * 1000)
|
| 8237 |
if save:
|
| 8238 |
_sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
|
|
|
|
|
|
|
| 8239 |
# Push changed rows to live (SSE) listeners, but only if someone is watching.
|
| 8240 |
if changed and _sm_lb_subscriber_count():
|
| 8241 |
try:
|
|
@@ -8273,6 +8442,8 @@ def _sm_leaderboard_ingest_rows(results, save: bool = True):
|
|
| 8273 |
state["updated_at"] = int(time.time() * 1000)
|
| 8274 |
if save:
|
| 8275 |
_sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
|
|
|
|
|
|
|
| 8276 |
if updated:
|
| 8277 |
_SM_LB_DIRTY.set() # neue Daten -> Hintergrund-Warmer rechnet die Views neu
|
| 8278 |
return updated
|
|
@@ -10274,6 +10445,11 @@ def _sm_inventory_result(username: str, inventory: dict):
|
|
| 10274 |
if isinstance(sword, dict):
|
| 10275 |
sword_name = _sm_gear_type_name(SWORD_TYPE_NAMES, SCHWERTER, sword.get("type"))
|
| 10276 |
result["sword"] = sword_name or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10277 |
try:
|
| 10278 |
result["sword_level"] = max(1, int(sword.get("level") or 1))
|
| 10279 |
except (TypeError, ValueError):
|
|
@@ -10286,6 +10462,10 @@ def _sm_inventory_result(username: str, inventory: dict):
|
|
| 10286 |
if isinstance(shield, dict):
|
| 10287 |
shield_name = _sm_gear_type_name(SHIELD_TYPE_NAMES, SCHILDE, shield.get("type"))
|
| 10288 |
result["shield"] = shield_name or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10289 |
try:
|
| 10290 |
result["shield_level"] = max(1, int(shield.get("level") or 1))
|
| 10291 |
except (TypeError, ValueError):
|
|
@@ -11963,6 +12143,43 @@ def discord_lookup(username: str, include_rank: bool = True):
|
|
| 11963 |
return result
|
| 11964 |
|
| 11965 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11966 |
@app.get("/live-rank")
|
| 11967 |
def live_rank(username: str):
|
| 11968 |
clean = str(username or "").strip()
|
|
|
|
| 141 |
# und die Anzahl blieb bei 500k haengen. Jetzt 2 Mio (Headroom) + Eviction nach Power (unten).
|
| 142 |
SM_LEADERBOARD_MAX_ROWS = max(1000, int(_os.getenv("SM_LEADERBOARD_MAX_ROWS", "2000000")))
|
| 143 |
|
| 144 |
+
# ── Power/Level/Gold Zeitreihe (pro Scan) ────────────────────────────────────────────────────
|
| 145 |
+
# Eigener, vom 600-MB-Leaderboard-Cache komplett GETRENNTER Store (eigene Datei, eigener Lock,
|
| 146 |
+
# eigenes kurzes Save-Intervall) -> beruehrt den kritischen Leaderboard-Schreibpfad NICHT.
|
| 147 |
+
# Nur Accounts mit >= 1 Qnt Power (1e18) bekommen Punkte; jeder Lookup/Ingest haengt (gedrosselt
|
| 148 |
+
# auf <=1 Punkt/Min/Account) einen SEKUNDENgenauen Punkt an. Punktformat (kompakt):
|
| 149 |
+
# [t_sec, level, power_float, gold_float, sword_type, shield_type]
|
| 150 |
+
# sword/shield = Spiel-Typ-Code (kleine Zahl) der AKTIVEN Waffe, -1 wenn unbekannt.
|
| 151 |
+
def _sm_resolve_power_series_path() -> Path:
|
| 152 |
+
configured = (_os.getenv("SM_POWER_SERIES_FILE") or "").strip()
|
| 153 |
+
if configured:
|
| 154 |
+
path = Path(configured)
|
| 155 |
+
return path if path.is_absolute() else APP_DIR / path
|
| 156 |
+
if _os.getenv("SPACE_ID"):
|
| 157 |
+
return Path("/data") / "power_series.json"
|
| 158 |
+
return APP_DIR / "power_series.json"
|
| 159 |
+
|
| 160 |
+
SM_POWER_SERIES_PATH = _sm_resolve_power_series_path()
|
| 161 |
+
SM_POWER_SERIES_MIN_POWER = float(_os.getenv("SM_POWER_SERIES_MIN_POWER", "1e18")) # 1 Qnt
|
| 162 |
+
SM_POWER_SERIES_MIN_GAP = max(0.0, float(_os.getenv("SM_POWER_SERIES_MIN_GAP", "60.0"))) # <=1 Punkt/Min/Account
|
| 163 |
+
SM_POWER_SERIES_MAX_POINTS = max(100, int(_os.getenv("SM_POWER_SERIES_MAX_POINTS", "20000"))) # Sicherheitsdeckel/Account
|
| 164 |
+
SM_POWER_SERIES_SAVE_INTERVAL = max(10.0, float(_os.getenv("SM_POWER_SERIES_SAVE_INTERVAL", "120.0")))
|
| 165 |
+
|
| 166 |
def _env_truthy(name: str, default: bool = False) -> bool:
|
| 167 |
value = _os.getenv(name)
|
| 168 |
if value is None:
|
|
|
|
| 8228 |
pass
|
| 8229 |
|
| 8230 |
|
| 8231 |
+
# ── Power/Level/Gold Zeitreihen-Store (separat, eigener Lock/Datei) ───────────────────────────
|
| 8232 |
+
_SM_PSERIES_LOCK = threading.RLock()
|
| 8233 |
+
_SM_PSERIES_STATE = None # {"v":1,"updated_at":ms,"series":{key:{"u":name,"p":[[...]]}}}
|
| 8234 |
+
_SM_PSERIES_DIRTY = False
|
| 8235 |
+
_SM_PSERIES_LAST_SAVE_MONOTONIC = 0.0
|
| 8236 |
+
_SM_PSERIES_FIELDS = ["t", "level", "power", "gold", "sword", "shield"]
|
| 8237 |
+
|
| 8238 |
+
|
| 8239 |
+
def _sm_pseries_read_file(path: Path):
|
| 8240 |
+
if not path.exists():
|
| 8241 |
+
return None
|
| 8242 |
+
blob = path.read_bytes()
|
| 8243 |
+
if blob[:2] == b"\x1f\x8b": # gzip-Magic
|
| 8244 |
+
blob = gzip.decompress(blob)
|
| 8245 |
+
data = json.loads(blob.decode("utf-8"))
|
| 8246 |
+
if isinstance(data, dict) and isinstance(data.get("series"), dict):
|
| 8247 |
+
return data
|
| 8248 |
+
return None
|
| 8249 |
+
|
| 8250 |
+
|
| 8251 |
+
def _sm_pseries_load_unlocked():
|
| 8252 |
+
global _SM_PSERIES_STATE
|
| 8253 |
+
if _SM_PSERIES_STATE is not None:
|
| 8254 |
+
return _SM_PSERIES_STATE
|
| 8255 |
+
state = {"v": 1, "updated_at": 0, "series": {}}
|
| 8256 |
+
try:
|
| 8257 |
+
data = _sm_pseries_read_file(SM_POWER_SERIES_PATH)
|
| 8258 |
+
if data:
|
| 8259 |
+
series = {}
|
| 8260 |
+
for key, entry in (data.get("series") or {}).items():
|
| 8261 |
+
if not isinstance(entry, dict) or not isinstance(entry.get("p"), list):
|
| 8262 |
+
continue
|
| 8263 |
+
series[str(key)] = {"u": str(entry.get("u") or ""), "p": entry.get("p")}
|
| 8264 |
+
state["series"] = series
|
| 8265 |
+
state["updated_at"] = int(data.get("updated_at") or 0)
|
| 8266 |
+
except Exception as exc: # noqa: BLE001
|
| 8267 |
+
print(f"[power-series load failed] {type(exc).__name__}: {str(exc)[:200]}")
|
| 8268 |
+
state = {"v": 1, "updated_at": 0, "series": {}}
|
| 8269 |
+
_SM_PSERIES_STATE = state
|
| 8270 |
+
return state
|
| 8271 |
+
|
| 8272 |
+
|
| 8273 |
+
def _sm_pseries_write_unlocked():
|
| 8274 |
+
state = _sm_pseries_load_unlocked()
|
| 8275 |
+
state["updated_at"] = int(time.time() * 1000)
|
| 8276 |
+
payload = json.dumps(state, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
|
| 8277 |
+
SM_POWER_SERIES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 8278 |
+
tmp = SM_POWER_SERIES_PATH.with_suffix(SM_POWER_SERIES_PATH.suffix + ".tmp")
|
| 8279 |
+
tmp.write_bytes(gzip.compress(payload, 4))
|
| 8280 |
+
tmp.replace(SM_POWER_SERIES_PATH)
|
| 8281 |
+
|
| 8282 |
+
|
| 8283 |
+
def _sm_pseries_save_unlocked(force: bool = False):
|
| 8284 |
+
"""Throttled: schreibt nur alle SM_POWER_SERIES_SAVE_INTERVAL Sekunden (ausser force)."""
|
| 8285 |
+
global _SM_PSERIES_DIRTY, _SM_PSERIES_LAST_SAVE_MONOTONIC
|
| 8286 |
+
if not _SM_PSERIES_DIRTY and not force:
|
| 8287 |
+
return False
|
| 8288 |
+
now = time.monotonic()
|
| 8289 |
+
if not force and _SM_PSERIES_LAST_SAVE_MONOTONIC:
|
| 8290 |
+
if now - _SM_PSERIES_LAST_SAVE_MONOTONIC < SM_POWER_SERIES_SAVE_INTERVAL:
|
| 8291 |
+
return False
|
| 8292 |
+
try:
|
| 8293 |
+
_sm_pseries_write_unlocked()
|
| 8294 |
+
_SM_PSERIES_LAST_SAVE_MONOTONIC = now
|
| 8295 |
+
_SM_PSERIES_DIRTY = False
|
| 8296 |
+
return True
|
| 8297 |
+
except Exception as exc: # noqa: BLE001
|
| 8298 |
+
print(f"[power-series save failed] {type(exc).__name__}: {str(exc)[:200]}")
|
| 8299 |
+
return False
|
| 8300 |
+
|
| 8301 |
+
|
| 8302 |
+
def _sm_pseries_gear_type(value):
|
| 8303 |
+
try:
|
| 8304 |
+
return int(value)
|
| 8305 |
+
except (TypeError, ValueError):
|
| 8306 |
+
return -1
|
| 8307 |
+
|
| 8308 |
+
|
| 8309 |
+
def _sm_power_series_record(result: dict):
|
| 8310 |
+
"""Haengt einen Zeitreihen-Punkt an, wenn der Account >= 1 Qnt Power hat. Billig: nur
|
| 8311 |
+
in-memory + throttled Datei-Write. Wird NACH dem Leaderboard-Lock pro Ergebnis aufgerufen
|
| 8312 |
+
(kein verschachtelter Lock)."""
|
| 8313 |
+
global _SM_PSERIES_DIRTY
|
| 8314 |
+
if not isinstance(result, dict) or not result.get("ok"):
|
| 8315 |
+
return
|
| 8316 |
+
power = _sm_float_sort_value(result.get("power_raw"))
|
| 8317 |
+
if power < SM_POWER_SERIES_MIN_POWER:
|
| 8318 |
+
return # unter 1 Qnt -> gar nicht speichern (Frontend zeigt "unter 1qnt")
|
| 8319 |
+
try:
|
| 8320 |
+
level = int(result.get("level"))
|
| 8321 |
+
except (TypeError, ValueError):
|
| 8322 |
+
return
|
| 8323 |
+
key = _sm_leaderboard_storage_key(result.get("username") or "", result.get("player_id") or "")
|
| 8324 |
+
if not key:
|
| 8325 |
+
return
|
| 8326 |
+
gold = _sm_float_sort_value(result.get("gold_raw"))
|
| 8327 |
+
sword = _sm_pseries_gear_type(result.get("sword_type"))
|
| 8328 |
+
shield = _sm_pseries_gear_type(result.get("shield_type"))
|
| 8329 |
+
now = int(time.time())
|
| 8330 |
+
with _SM_PSERIES_LOCK:
|
| 8331 |
+
state = _sm_pseries_load_unlocked()
|
| 8332 |
+
series = state["series"]
|
| 8333 |
+
entry = series.get(key)
|
| 8334 |
+
if entry is None:
|
| 8335 |
+
entry = {"u": str(result.get("username") or ""), "p": []}
|
| 8336 |
+
series[key] = entry
|
| 8337 |
+
else:
|
| 8338 |
+
uname = str(result.get("username") or "")
|
| 8339 |
+
if uname:
|
| 8340 |
+
entry["u"] = uname
|
| 8341 |
+
pts = entry["p"]
|
| 8342 |
+
if pts:
|
| 8343 |
+
try:
|
| 8344 |
+
if now - int(pts[-1][0]) < SM_POWER_SERIES_MIN_GAP:
|
| 8345 |
+
return # zu frisch -> Spam-Schutz (<=1 Punkt/Min/Account)
|
| 8346 |
+
except (TypeError, ValueError, IndexError):
|
| 8347 |
+
pass
|
| 8348 |
+
pts.append([now, level, power, gold, sword, shield])
|
| 8349 |
+
if len(pts) > SM_POWER_SERIES_MAX_POINTS:
|
| 8350 |
+
del pts[: len(pts) - SM_POWER_SERIES_MAX_POINTS]
|
| 8351 |
+
_SM_PSERIES_DIRTY = True
|
| 8352 |
+
_sm_pseries_save_unlocked(force=False)
|
| 8353 |
+
|
| 8354 |
+
|
| 8355 |
+
def _sm_power_series_record_many(results):
|
| 8356 |
+
for result in results or []:
|
| 8357 |
+
try:
|
| 8358 |
+
_sm_power_series_record(result)
|
| 8359 |
+
except Exception: # noqa: BLE001
|
| 8360 |
+
pass
|
| 8361 |
+
|
| 8362 |
+
|
| 8363 |
+
def _sm_power_series_flusher():
|
| 8364 |
+
while True:
|
| 8365 |
+
time.sleep(SM_POWER_SERIES_SAVE_INTERVAL)
|
| 8366 |
+
try:
|
| 8367 |
+
with _SM_PSERIES_LOCK:
|
| 8368 |
+
_sm_pseries_save_unlocked(force=False)
|
| 8369 |
+
except Exception: # noqa: BLE001
|
| 8370 |
+
pass
|
| 8371 |
+
|
| 8372 |
+
|
| 8373 |
+
threading.Thread(target=_sm_power_series_flusher, name="sm-power-series-flush", daemon=True).start()
|
| 8374 |
+
|
| 8375 |
+
|
| 8376 |
def _sm_leaderboard_upsert_lookup_results(results, source: str = "lookup", save: bool = True):
|
| 8377 |
updated = 0
|
| 8378 |
changed = []
|
|
|
|
| 8403 |
state["updated_at"] = int(time.time() * 1000)
|
| 8404 |
if save:
|
| 8405 |
_sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
|
| 8406 |
+
# Zeitreihe (>= 1 Qnt) ausserhalb des Leaderboard-Locks mitschreiben.
|
| 8407 |
+
_sm_power_series_record_many(results)
|
| 8408 |
# Push changed rows to live (SSE) listeners, but only if someone is watching.
|
| 8409 |
if changed and _sm_lb_subscriber_count():
|
| 8410 |
try:
|
|
|
|
| 8442 |
state["updated_at"] = int(time.time() * 1000)
|
| 8443 |
if save:
|
| 8444 |
_sm_leaderboard_save_unlocked(min_interval=SM_LEADERBOARD_SAVE_INTERVAL)
|
| 8445 |
+
# Zeitreihe (>= 1 Qnt) ausserhalb des Leaderboard-Locks mitschreiben.
|
| 8446 |
+
_sm_power_series_record_many(results)
|
| 8447 |
if updated:
|
| 8448 |
_SM_LB_DIRTY.set() # neue Daten -> Hintergrund-Warmer rechnet die Views neu
|
| 8449 |
return updated
|
|
|
|
| 10445 |
if isinstance(sword, dict):
|
| 10446 |
sword_name = _sm_gear_type_name(SWORD_TYPE_NAMES, SCHWERTER, sword.get("type"))
|
| 10447 |
result["sword"] = sword_name or ""
|
| 10448 |
+
# Roher Spiel-Typ-Code der AKTIVEN Waffe (kleine Zahl) fuer die Zeitreihe/Translation.
|
| 10449 |
+
try:
|
| 10450 |
+
result["sword_type"] = int(sword.get("type"))
|
| 10451 |
+
except (TypeError, ValueError):
|
| 10452 |
+
pass
|
| 10453 |
try:
|
| 10454 |
result["sword_level"] = max(1, int(sword.get("level") or 1))
|
| 10455 |
except (TypeError, ValueError):
|
|
|
|
| 10462 |
if isinstance(shield, dict):
|
| 10463 |
shield_name = _sm_gear_type_name(SHIELD_TYPE_NAMES, SCHILDE, shield.get("type"))
|
| 10464 |
result["shield"] = shield_name or ""
|
| 10465 |
+
try:
|
| 10466 |
+
result["shield_type"] = int(shield.get("type"))
|
| 10467 |
+
except (TypeError, ValueError):
|
| 10468 |
+
pass
|
| 10469 |
try:
|
| 10470 |
result["shield_level"] = max(1, int(shield.get("level") or 1))
|
| 10471 |
except (TypeError, ValueError):
|
|
|
|
| 12143 |
return result
|
| 12144 |
|
| 12145 |
|
| 12146 |
+
@app.get("/power-series")
|
| 12147 |
+
def power_series(username: str = "", player_id: str = ""):
|
| 12148 |
+
"""Power/Level/Gold-Zeitreihe eines Accounts (nur Accounts mit >= 1 Qnt Power).
|
| 12149 |
+
Bevorzugt player_id (exakter Match); username als Fallback. points = Liste von
|
| 12150 |
+
[t_sec, level, power, gold, sword_type, shield_type]. Fuer das Frontend-Diagramm."""
|
| 12151 |
+
key = _sm_leaderboard_storage_key(username or "", player_id or "")
|
| 12152 |
+
if not key:
|
| 12153 |
+
raise HTTPException(400, "username oder player_id erforderlich.")
|
| 12154 |
+
with _SM_PSERIES_LOCK:
|
| 12155 |
+
state = _sm_pseries_load_unlocked()
|
| 12156 |
+
series = state.get("series") or {}
|
| 12157 |
+
entry = series.get(key)
|
| 12158 |
+
# Fallback: nur Name angegeben, gespeichert ist aber unter id:<player_id>.
|
| 12159 |
+
if entry is None and username and not player_id:
|
| 12160 |
+
want = _discord_lookup_key(username)
|
| 12161 |
+
for cand_key, cand in series.items():
|
| 12162 |
+
if _discord_lookup_key(cand.get("u")) == want:
|
| 12163 |
+
entry, key = cand, cand_key
|
| 12164 |
+
break
|
| 12165 |
+
if entry is None:
|
| 12166 |
+
# Kein Datensatz -> entweder noch nie >= 1 Qnt gescannt, oder unter der Schwelle.
|
| 12167 |
+
return {"ok": True, "key": key, "under_1qnt": True, "count": 0,
|
| 12168 |
+
"fields": _SM_PSERIES_FIELDS, "min_power": SM_POWER_SERIES_MIN_POWER, "points": []}
|
| 12169 |
+
pts = list(entry.get("p") or [])
|
| 12170 |
+
uname = entry.get("u") or username
|
| 12171 |
+
return {
|
| 12172 |
+
"ok": True,
|
| 12173 |
+
"key": key,
|
| 12174 |
+
"username": uname,
|
| 12175 |
+
"under_1qnt": False,
|
| 12176 |
+
"min_power": SM_POWER_SERIES_MIN_POWER,
|
| 12177 |
+
"fields": _SM_PSERIES_FIELDS,
|
| 12178 |
+
"count": len(pts),
|
| 12179 |
+
"points": pts,
|
| 12180 |
+
}
|
| 12181 |
+
|
| 12182 |
+
|
| 12183 |
@app.get("/live-rank")
|
| 12184 |
def live_rank(username: str):
|
| 12185 |
clean = str(username or "").strip()
|