mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
196 lines
7.5 KiB
Python
Executable File
196 lines
7.5 KiB
Python
Executable File
#!/usr/bin/python3
|
|
"""The app's GOG store talks to Heroic's gogdl through this: one JSON object per line on stdout,
|
|
so progress reaches the front end as numbers rather than log lines. The app puts gogdl in the
|
|
runtime (GOGDL below) and keeps the sign-in in its own files (the auth path every command takes).
|
|
Tokens never leave on stdout except from "token", whose output the app does not log.
|
|
|
|
droiddeck-gog login <auth> <code> trades the sign-in page's code for tokens
|
|
droiddeck-gog token <auth> a current access token (refreshed when due)
|
|
droiddeck-gog info <auth> <id> folder name and sizes of the Windows build
|
|
droiddeck-gog install <auth> <id> <base> downloads into <base>/<folder name>
|
|
droiddeck-gog update <auth> <id> <dir> brings an installed game to the latest build
|
|
|
|
Lines: {"e":"info",...}, {"e":"progress","percent":45,"written":..,"total":..},
|
|
{"e":"error","message":"..."} and {"e":"done",...} at the end. Exit status 0 on success.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
GOGDL = "/opt/droiddeck/gogdl/gogdl"
|
|
PLATFORM = "windows"
|
|
MARKER = ".droiddeck-gog.json"
|
|
PROGRESS = re.compile(r"= Progress: ([\d.]+) (\d+)/(\d+)")
|
|
# gogdl's own log lines: "[NAME] LEVEL: message".
|
|
LOG = re.compile(r"^\[([^\]]+)\] (\w+): (.*)$")
|
|
|
|
|
|
def emit(**kw):
|
|
sys.stdout.write(json.dumps(kw) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def gogdl(auth, *args):
|
|
return [sys.executable or "/usr/bin/python3", GOGDL, "--auth-config-path", auth] + list(args)
|
|
|
|
|
|
def last_json(text):
|
|
"""gogdl prints its answer as the last JSON line of stdout, after anything else it says."""
|
|
for line in reversed(text.splitlines()):
|
|
line = line.strip()
|
|
if line.startswith("{") or line == "null":
|
|
try:
|
|
return json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def quiet(auth, *args):
|
|
"""Runs a gogdl command that answers in JSON; its log (stderr) is dropped, never echoed."""
|
|
run = subprocess.run(gogdl(auth, *args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
return run.returncode, last_json(run.stdout), run.stderr
|
|
|
|
|
|
def login(auth, code):
|
|
os.makedirs(os.path.dirname(auth) or ".", exist_ok=True)
|
|
status, data, _ = quiet(auth, "auth", "--code", code)
|
|
if status != 0 or not isinstance(data, dict) or data.get("error") or not data.get("access_token"):
|
|
emit(e="error", message="GOG did not accept the sign-in")
|
|
return 1
|
|
emit(e="done", user_id=str(data.get("user_id", "")))
|
|
return 0
|
|
|
|
|
|
def token(auth):
|
|
if not os.path.isfile(auth):
|
|
emit(e="error", message="signed out")
|
|
return 1
|
|
status, data, _ = quiet(auth, "auth")
|
|
if status != 0 or not isinstance(data, dict) or not data.get("access_token"):
|
|
emit(e="error", message="signed out")
|
|
return 1
|
|
expires = float(data.get("loginTime", 0)) + float(data.get("expires_in", 0))
|
|
emit(e="token", access_token=data["access_token"], user_id=str(data.get("user_id", "")), expires=int(expires))
|
|
return 0
|
|
|
|
|
|
def sizes(size, language):
|
|
"""Bytes to download and on disk: the depots every language needs ("*") plus one language's."""
|
|
pick = [k for k in ("*", language) if k in size]
|
|
if language not in size:
|
|
pick += [k for k in size if k != "*"][:1]
|
|
return (sum(int(size[k].get("download_size") or 0) for k in pick),
|
|
sum(int(size[k].get("disk_size") or 0) for k in pick))
|
|
|
|
|
|
def language_of(data):
|
|
langs = data.get("languages") or []
|
|
for want in ("en-US", "en"):
|
|
if want in langs:
|
|
return want
|
|
return langs[0] if langs else "en-US"
|
|
|
|
|
|
def info(auth, game_id, out=True):
|
|
status, data, err = quiet(auth, "info", game_id, "--platform", PLATFORM, "--skip-dlcs")
|
|
if status != 0 or not isinstance(data, dict) or not data.get("folder_name"):
|
|
reason = failure(err) or "GOG has no Windows build of this game to download"
|
|
if out:
|
|
emit(e="error", message=reason)
|
|
return None, reason
|
|
language = language_of(data)
|
|
download, disk = sizes(data.get("size") or {}, language)
|
|
result = dict(folder=data["folder_name"], language=language, download_size=download, disk_size=disk,
|
|
build=str(data.get("buildId", "")))
|
|
if out:
|
|
emit(e="info", **result)
|
|
return result, None
|
|
|
|
|
|
def failure(text):
|
|
"""The last error gogdl logged, for the user; None when it logged none."""
|
|
errors = [m.group(3) for m in (LOG.match(line) for line in (text or "").splitlines())
|
|
if m and m.group(2) in ("ERROR", "CRITICAL")]
|
|
return errors[-1] if errors else None
|
|
|
|
|
|
def follow(argv):
|
|
"""Runs a download, turning its progress lines into ours. Returns (status, last error)."""
|
|
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
|
error, last = None, -1
|
|
for line in proc.stdout:
|
|
line = line.rstrip("\n")
|
|
m = PROGRESS.search(line)
|
|
if m:
|
|
percent = int(float(m.group(1)))
|
|
if percent != last:
|
|
last = percent
|
|
emit(e="progress", percent=percent, written=int(m.group(2)), total=int(m.group(3)))
|
|
continue
|
|
log = LOG.match(line)
|
|
if log and log.group(2) in ("ERROR", "CRITICAL"):
|
|
error = log.group(3)
|
|
# Everything else goes to the app's log of this command as it is.
|
|
sys.stdout.write(line + "\n")
|
|
sys.stdout.flush()
|
|
return proc.wait(), error
|
|
|
|
|
|
def install(auth, game_id, base):
|
|
data, _ = info(auth, game_id)
|
|
if data is None:
|
|
return 1
|
|
target = os.path.join(base, data["folder"])
|
|
os.makedirs(target, exist_ok=True)
|
|
# Which game a folder is, from the first byte: the app lists a stopped download by it.
|
|
with open(os.path.join(target, MARKER), "w", encoding="utf-8") as f:
|
|
json.dump(dict(id=game_id, build=data["build"], language=data["language"]), f)
|
|
status, error = follow(gogdl(auth, "download", game_id, "--platform", PLATFORM, "--path", base,
|
|
"--skip-dlcs", "--lang", data["language"]))
|
|
if status != 0 or not os.path.isfile(os.path.join(target, "goggame-%s.info" % game_id)):
|
|
emit(e="error", message=error or "the download stopped (gogdl exit %d)" % status)
|
|
return 1
|
|
emit(e="done", dir=target)
|
|
return 0
|
|
|
|
|
|
def update(auth, game_id, target):
|
|
data, _ = info(auth, game_id, out=False)
|
|
language = data["language"] if data else "en-US"
|
|
status, error = follow(gogdl(auth, "update", game_id, "--platform", PLATFORM, "--path", target,
|
|
"--skip-dlcs", "--lang", language))
|
|
if status != 0:
|
|
emit(e="error", message=error or "the update stopped (gogdl exit %d)" % status)
|
|
return 1
|
|
emit(e="done", dir=target)
|
|
return 0
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) < 3:
|
|
print(__doc__)
|
|
return 2
|
|
verb, auth, rest = argv[1], argv[2], argv[3:]
|
|
if not os.path.isfile(GOGDL):
|
|
emit(e="error", message="gogdl is not set up")
|
|
return 1
|
|
if verb == "login" and len(rest) == 1:
|
|
return login(auth, rest[0])
|
|
if verb == "token" and not rest:
|
|
return token(auth)
|
|
if verb == "info" and len(rest) == 1:
|
|
return 0 if info(auth, rest[0])[0] else 1
|
|
if verb == "install" and len(rest) == 2:
|
|
return install(auth, rest[0], rest[1])
|
|
if verb == "update" and len(rest) == 2:
|
|
return update(auth, rest[0], rest[1])
|
|
print(__doc__)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|