Skip to content

Instantly share code, notes, and snippets.

@Barina
Created March 22, 2026 09:38
Show Gist options
  • Select an option

  • Save Barina/eeb212c09dd12c475969d40953b6b5bd to your computer and use it in GitHub Desktop.

Select an option

Save Barina/eeb212c09dd12c475969d40953b6b5bd to your computer and use it in GitHub Desktop.
Playnite game activity to Ryot

playnite-to-ryot

Converts your Playnite game library to a Ryot Generic JSON import file.

Compatibility

Component Version
Playnite 10.x
GameActivity extension any recent
Ryot v10.x
Ryot import format CompleteExport (Generic JSON)

How it works

Playnite's GameActivity extension stores one JSON file per game under:

%AppData%\Roaming\Playnite\ExtensionsData\afbb1a0d-04a1-4d0c-9afa-c6e42ca855b4\GameActivity\

Each file contains session history with timestamps and playtime in seconds. This script:

  1. Reads all those files directly (no plugins, no LiteDB access needed)
  2. Looks up each game's IGDB ID by name (so Ryot can fetch proper metadata/covers)
  3. Outputs a ryot_import.json in Ryot's CompleteExport Generic JSON format

Requirements

  • Python 3.10+
  • pip install requests
  • A free Twitch developer account (for IGDB API access)

Setup

1. Get IGDB credentials (free)

IGDB is owned by Twitch and requires a free Twitch account:

  1. Go to dev.twitch.tv/console and log in
  2. Click Register Your Application
    • Name: anything (e.g. "playnite-export")
    • OAuth Redirect URL: http://localhost
    • Category: Other
  3. Click Manage on your new app
  4. Copy your Client ID
  5. Click New Secret and copy the Client Secret

2. Run the script

pip install requests

python playnite_to_ryot.py \
  --client-id YOUR_CLIENT_ID \
  --client-secret YOUR_CLIENT_SECRET

With a large library (~1000+ games) this takes a few minutes due to IGDB's rate limit of ~4 requests/second. Progress is saved to igdb_cache.json as it goes — if interrupted, just rerun and it'll skip already-looked-up games.

Re-run without API calls (uses cached results):

python playnite_to_ryot.py --no-igdb

Custom paths:

python playnite_to_ryot.py \
  --client-id YOUR_ID \
  --client-secret YOUR_SECRET \
  --activity-dir "D:\Playnite\ExtensionsData\afbb1a0d-04a1-4d0c-9afa-c6e42ca855b4\GameActivity" \
  --output ryot_import.json \
  --verbose

3. Import into Ryot

  1. Open Ryot → Settings → Imports & Exports → Import
  2. Select Generic JSON
  3. Upload ryot_import.json
  4. Wait — Ryot fetches metadata for each game from IGDB, which takes a while for large libraries

Known limitations

No completion status

GameActivity only records play sessions — it doesn't know if you actually finished a game. All games with playtime are imported as In Progress rather than Completed. If you want accurate completion status you'd need to cross-reference Playnite's games.db (LiteDB format), which requires additional tooling.

Non-IGDB games

Manually added games or obscure titles not found on IGDB fall back to source: "custom". Ryot will create entries for these but won't be able to fetch cover art or metadata automatically.

Session granularity

Ryot's video game tracking model is borrowed from its movie/TV watching model — it treats each seen_history entry as a single viewing session. Playnite's multi-session model (many short play sessions over time) doesn't map perfectly. The import collapses all sessions into one entry with total playtime.

Files

File Description
playnite_to_ryot.py Main conversion script
igdb_cache.json Auto-generated cache of IGDB lookups (reused on reruns)
ryot_import.json Output file to upload to Ryot
#!/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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment