|
#!/usr/bin/env python3 |
|
""" |
|
playnite_to_ryot.py |
|
------------------- |
|
Converts Playnite GameActivity extension data to a Ryot Generic JSON import file. |
|
|
|
Tested with: |
|
- Playnite 10.x |
|
- GameActivity extension (afbb1a0d-04a1-4d0c-9afa-c6e42ca855b4) |
|
- Ryot v10.x (Generic JSON / CompleteExport schema) |
|
|
|
Requirements: |
|
pip install requests |
|
|
|
Usage: |
|
python playnite_to_ryot.py --client-id YOUR_ID --client-secret YOUR_SECRET |
|
python playnite_to_ryot.py --no-igdb # reuses igdb_cache.json from a previous run |
|
|
|
See README.md for full setup instructions. |
|
""" |
|
|
|
import argparse |
|
import json |
|
import os |
|
import sys |
|
import time |
|
from datetime import datetime, timezone |
|
from pathlib import Path |
|
|
|
try: |
|
import requests |
|
except ImportError: |
|
print("ERROR: run: pip install requests", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
# Default GameActivity folder (Playnite installed mode, Windows) |
|
GAMEACTIVITY_DIR = ( |
|
Path.home() |
|
/ "AppData/Roaming/Playnite/ExtensionsData" |
|
/ "afbb1a0d-04a1-4d0c-9afa-c6e42ca855b4" |
|
/ "GameActivity" |
|
) |
|
|
|
# --------------------------------------------------------------------------- |
|
# IGDB |
|
# --------------------------------------------------------------------------- |
|
|
|
def get_igdb_token(client_id: str, client_secret: str) -> str: |
|
r = requests.post( |
|
"https://id.twitch.tv/oauth2/token", |
|
params={ |
|
"client_id": client_id, |
|
"client_secret": client_secret, |
|
"grant_type": "client_credentials", |
|
}, |
|
timeout=10, |
|
) |
|
r.raise_for_status() |
|
return r.json()["access_token"] |
|
|
|
|
|
def igdb_search(name: str, client_id: str, token: str) -> str | None: |
|
"""Return the best-matching IGDB game ID as a string, or None.""" |
|
for query in [ |
|
# Prefer main games / remakes / remasters |
|
f'search "{name}"; fields id,name; where category = (0,8,9); limit 1;', |
|
# Fallback: any category |
|
f'search "{name}"; fields id,name; limit 1;', |
|
]: |
|
try: |
|
r = requests.post( |
|
"https://api.igdb.com/v4/games", |
|
headers={ |
|
"Client-ID": client_id, |
|
"Authorization": f"Bearer {token}", |
|
}, |
|
data=query, |
|
timeout=10, |
|
) |
|
if r.status_code == 429: |
|
time.sleep(1) |
|
continue |
|
r.raise_for_status() |
|
results = r.json() |
|
if results: |
|
return str(results[0]["id"]) |
|
except Exception: |
|
pass |
|
time.sleep(0.26) # stay within IGDB's ~4 req/s free tier |
|
return None |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Timestamp helpers |
|
# --------------------------------------------------------------------------- |
|
|
|
def normalize_ts(value: str | None) -> str | None: |
|
""" |
|
Normalize a Playnite ISO 8601 timestamp for Ryot. |
|
Playnite stores e.g. "2026-01-14T00:23:32.7330947Z" (7 sub-second digits). |
|
Ryot's Rust deserializer wants at most 6 sub-second digits. |
|
Plain dates ("2025-05-22") get a T00:00:00Z suffix. |
|
""" |
|
if not value: |
|
return None |
|
s = str(value).strip() |
|
if "T" not in s: |
|
return s + "T00:00:00Z" |
|
if "." in s: |
|
base, frac = s.split(".", 1) |
|
suffix = "Z" if frac.endswith("Z") else "" |
|
frac = frac.rstrip("Z")[:6] |
|
return f"{base}.{frac}{suffix}" |
|
return s |
|
|
|
|
|
def now_ts() -> str: |
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Ryot schema helpers |
|
# --------------------------------------------------------------------------- |
|
|
|
def make_collection_entry(name: str) -> dict: |
|
""" |
|
Ryot v10 CompleteExport schema: CollectionToEntityDetails object. |
|
collection_id and creator_user_id can be empty strings for imports. |
|
""" |
|
ts = now_ts() |
|
return { |
|
"collection_id": "", |
|
"collection_name": name, |
|
"creator_user_id": "", |
|
"created_on": ts, |
|
"last_updated_on": ts, |
|
"information": None, |
|
} |
|
|
|
|
|
def make_seen_entry( |
|
state: str, |
|
started_on: str | None, |
|
ended_on: str | None, |
|
total_seconds: int, |
|
) -> dict: |
|
return { |
|
"state": state, |
|
"started_on": started_on, |
|
"ended_on": ended_on, |
|
"manual_time_spent": str(total_seconds) if total_seconds else None, |
|
# Fields required by schema but irrelevant for video games: |
|
"anime_episode_number": None, |
|
"manga_chapter_number": None, |
|
"manga_volume_number": None, |
|
"podcast_episode_number": None, |
|
"progress": None, |
|
"providers_consumed_on": None, |
|
"show_episode_number": None, |
|
"show_season_number": None, |
|
} |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
# Main conversion |
|
# --------------------------------------------------------------------------- |
|
|
|
def convert( |
|
activity_dir: Path, |
|
output_path: Path, |
|
client_id: str, |
|
client_secret: str, |
|
verbose: bool, |
|
no_igdb: bool, |
|
): |
|
files = sorted(activity_dir.glob("*.json")) |
|
if not files: |
|
print(f"ERROR: No .json files found in:\n {activity_dir}", file=sys.stderr) |
|
sys.exit(1) |
|
print(f"Found {len(files)} GameActivity files.") |
|
|
|
# IGDB auth |
|
token = None |
|
if not no_igdb: |
|
print("Authenticating with IGDB...", end=" ", flush=True) |
|
try: |
|
token = get_igdb_token(client_id, client_secret) |
|
print("OK") |
|
except Exception as e: |
|
print(f"FAILED ({e})\nContinuing without IGDB — games will use 'custom' source.") |
|
|
|
# Load/init IGDB cache (persisted next to output file) |
|
cache_path = output_path.parent / "igdb_cache.json" |
|
igdb_cache: dict[str, str | None] = {} |
|
if cache_path.exists(): |
|
try: |
|
igdb_cache = json.loads(cache_path.read_text(encoding="utf-8")) |
|
print(f"Loaded {len(igdb_cache)} cached IGDB lookups from {cache_path.name}") |
|
except Exception: |
|
pass |
|
|
|
print() |
|
|
|
metadata_items: list[dict] = [] |
|
stats = {"igdb": 0, "custom": 0, "watchlist": 0, "skipped": 0} |
|
|
|
for i, f in enumerate(files): |
|
try: |
|
data = json.loads(f.read_text(encoding="utf-8", errors="replace")) |
|
except Exception as e: |
|
print(f" Warning: could not read {f.name}: {e}", file=sys.stderr) |
|
continue |
|
|
|
name = (data.get("Name") or "").strip() |
|
if not name: |
|
continue |
|
|
|
# GameExist=false means the game was removed from Playnite |
|
if not data.get("GameExist", True): |
|
stats["skipped"] += 1 |
|
if verbose: |
|
print(f" [deleted ] {name}") |
|
continue |
|
|
|
sessions = data.get("Items") or [] |
|
total_seconds = int( |
|
data.get("SessionPlaytime") |
|
or sum(s.get("ElapsedSeconds", 0) for s in sessions) |
|
or 0 |
|
) |
|
has_playtime = total_seconds > 0 |
|
|
|
# Use the raw DateSession timestamps (full ISO 8601) for accuracy |
|
session_dates = [s["DateSession"] for s in sessions if s.get("DateSession")] |
|
started_on = normalize_ts(min(session_dates)) if session_dates else None |
|
ended_on = normalize_ts(max(session_dates)) if session_dates else None |
|
|
|
# IGDB lookup (with cache) |
|
igdb_id: str | None = None |
|
if token and not no_igdb: |
|
if name in igdb_cache: |
|
igdb_id = igdb_cache[name] |
|
else: |
|
igdb_id = igdb_search(name, client_id, token) |
|
igdb_cache[name] = igdb_id |
|
# Persist cache after every lookup so progress survives interruptions |
|
cache_path.write_text( |
|
json.dumps(igdb_cache, indent=2, ensure_ascii=False), |
|
encoding="utf-8", |
|
) |
|
time.sleep(0.26) |
|
elif name in igdb_cache: |
|
igdb_id = igdb_cache[name] |
|
|
|
if igdb_id: |
|
source, identifier = "igdb", igdb_id |
|
stats["igdb"] += 1 |
|
else: |
|
source, identifier = "custom", name |
|
stats["custom"] += 1 |
|
|
|
# ---------------------------------------------------------------- |
|
# Ryot seen_history / collections |
|
# |
|
# Limitation: GameActivity has no completion status — only sessions. |
|
# We use "in_progress" for games with playtime (since we can't know |
|
# if they were actually completed), and Watchlist for unplayed games. |
|
# If you want to mark games as "completed", re-run after adding |
|
# completion status support via Playnite's own DB export. |
|
# ---------------------------------------------------------------- |
|
collections: list[dict] = [] |
|
seen_history: list[dict] = [] |
|
|
|
if not has_playtime: |
|
# No recorded sessions — add to Watchlist |
|
collections = [make_collection_entry("Watchlist")] |
|
stats["watchlist"] += 1 |
|
else: |
|
# Has playtime but unknown completion — use in_progress |
|
# ended_on is the last session date, not necessarily finish date |
|
seen_history = [make_seen_entry( |
|
state = "in_progress", |
|
started_on = started_on, |
|
ended_on = None, # don't imply completion |
|
total_seconds = total_seconds, |
|
)] |
|
|
|
if verbose: |
|
hrs = total_seconds // 3600 |
|
tag = f"igdb:{igdb_id}" if igdb_id else "custom" |
|
wl = " [watchlist]" if not has_playtime else f" {hrs}h" |
|
print(f" [{tag:20s}] {name[:55]:<55}{wl}") |
|
elif (i + 1) % 100 == 0: |
|
print(f" ... {i+1}/{len(files)}") |
|
|
|
metadata_items.append({ |
|
"identifier": identifier, |
|
"lot": "video_game", |
|
"source": source, |
|
"source_id": name, |
|
"collections": collections, |
|
"reviews": [], |
|
"seen_history": seen_history, |
|
}) |
|
|
|
# Save final cache |
|
if igdb_cache: |
|
cache_path.write_text( |
|
json.dumps(igdb_cache, indent=2, ensure_ascii=False), |
|
encoding="utf-8", |
|
) |
|
|
|
# Write Ryot CompleteExport |
|
ryot_export = { |
|
"metadata": metadata_items, |
|
"metadata_groups": None, |
|
"people": None, |
|
"collections": None, |
|
"exercises": None, |
|
"measurements": None, |
|
"workouts": None, |
|
"workout_templates": None, |
|
} |
|
output_path.write_text( |
|
json.dumps(ryot_export, indent=2, ensure_ascii=False), |
|
encoding="utf-8", |
|
) |
|
|
|
print(f"\n✓ Wrote {len(metadata_items)} games → {output_path.resolve()}") |
|
print(f"\n IGDB matched : {stats['igdb']}") |
|
print(f" Custom fallback: {stats['custom']}") |
|
print(f" Watchlist : {stats['watchlist']}") |
|
print(f" Skipped : {stats['skipped']}") |
|
print() |
|
print("Next: Ryot → Settings → Imports & Exports → Import → Generic JSON") |
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
|
|
def main(): |
|
p = argparse.ArgumentParser( |
|
description="Convert Playnite GameActivity data to Ryot Generic JSON (v10).", |
|
formatter_class=argparse.RawDescriptionHelpFormatter, |
|
epilog=""" |
|
Examples: |
|
# Full run with IGDB lookup: |
|
python playnite_to_ryot.py --client-id abc123 --client-secret xyz789 |
|
|
|
# Re-run using cached IGDB results (no API calls): |
|
python playnite_to_ryot.py --no-igdb |
|
|
|
# Custom paths: |
|
python playnite_to_ryot.py --client-id abc123 --client-secret xyz789 \\ |
|
--activity-dir "C:\\\\Users\\\\Roy\\\\AppData\\\\Roaming\\\\Playnite\\\\ExtensionsData\\\\afbb1a0d-04a1-4d0c-9afa-c6e42ca855b4\\\\GameActivity" \\ |
|
--output ryot_import.json --verbose |
|
""", |
|
) |
|
p.add_argument("--activity-dir", default=str(GAMEACTIVITY_DIR), |
|
help="Path to GameActivity extension data folder") |
|
p.add_argument("--output", default="ryot_import.json", |
|
help="Output file path (default: ryot_import.json)") |
|
p.add_argument("--client-id", default=os.environ.get("IGDB_CLIENT_ID", ""), |
|
help="Twitch/IGDB Client ID (or set IGDB_CLIENT_ID env var)") |
|
p.add_argument("--client-secret", default=os.environ.get("IGDB_CLIENT_SECRET", ""), |
|
help="Twitch/IGDB Client Secret (or set IGDB_CLIENT_SECRET env var)") |
|
p.add_argument("--no-igdb", action="store_true", |
|
help="Skip IGDB API calls; still reads igdb_cache.json if present") |
|
p.add_argument("--verbose", "-v", action="store_true", |
|
help="Print each game as it's processed") |
|
args = p.parse_args() |
|
|
|
if not args.no_igdb and (not args.client_id or not args.client_secret): |
|
print("ERROR: --client-id and --client-secret are required for IGDB lookup.", file=sys.stderr) |
|
print(" Get free credentials at: https://dev.twitch.tv/console", file=sys.stderr) |
|
print(" Or use --no-igdb to skip (games without cache → custom source).", file=sys.stderr) |
|
sys.exit(1) |
|
|
|
convert( |
|
activity_dir = Path(args.activity_dir), |
|
output_path = Path(args.output), |
|
client_id = args.client_id, |
|
client_secret = args.client_secret, |
|
verbose = args.verbose, |
|
no_igdb = args.no_igdb, |
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |