Skip to content

Commit 3fe7b9f

Browse files
authored
Merge pull request #625 from tim-bellette/latest
Import played games and hours from Xbox via OpenXB
2 parents 1fc90ee + e94b632 commit 3fe7b9f

25 files changed

Lines changed: 3270 additions & 16 deletions

File tree

docs/agents/xbox_integration.md

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
# Xbox Integration (OpenXBL)
2+
3+
Reference for the Xbox game importer: how a user connects it, how many OpenXBL
4+
requests a sync costs, what data is stored and logged on each side, and exactly
5+
what disconnecting does and does not do.
6+
7+
Xbox Live has no public API. Floppy talks to **OpenXBL** (`https://xbl.io`), a
8+
third-party gateway, using a per-user API key the user pastes in. There is no
9+
OAuth flow and no instance-wide key: every user brings their own.
10+
11+
Code: `src/integrations/xbox_api.py` (client),
12+
`src/integrations/imports/xbox.py` (importer),
13+
`src/integrations/views.py:1912` (connect) and `:1949` (disconnect),
14+
`src/integrations/models.py:768` (`XboxAccount`).
15+
16+
External facts below were checked against xbl.io on 2026-08-12 and are quoted;
17+
OpenXBL can change them without notice.
18+
19+
## Setup
20+
21+
1. The user signs in at [xbl.io](https://xbl.io) with their Microsoft account.
22+
OpenXBL also requires phone verification before it issues a key
23+
("Sign up at xbl.io, verify your phone number, and grab your key from the
24+
dashboard" — [xbl.io/getting-started](https://xbl.io/getting-started)).
25+
2. They copy the API key from the OpenXBL dashboard.
26+
3. In Floppy: **Settings → Import → Xbox**, paste the key, press **Connect Xbox**
27+
(`POST /import/xbox/connect`).
28+
29+
What connect does, in order (`xbox_connect` in `src/integrations/views.py:1912`):
30+
31+
- Calls `GET /api/v2/account` immediately to validate the key. A bad key fails
32+
here with a message, and nothing is stored.
33+
- Stores an `XboxAccount` row: the key Fernet-encrypted, plus the returned
34+
`xuid` and `gamertag`, with `connection_broken=False`.
35+
- Starts an import right away, using whatever frequency/time/mode the shared
36+
import modal has selected — one-off `Import from Xbox`, or a recurring
37+
`Import from Xbox (Recurring)` schedule at the chosen time
38+
(`_start_xbox_import` in `src/integrations/views.py:1893`).
39+
40+
Recurring schedules support `daily` or `2days` only, defaulting to 04:00 local
41+
(`XBOX_RECURRING_FREQUENCIES` in `src/integrations/views.py:1761`).
42+
43+
Import modes: only `new` and `overwrite` are meaningful. Xbox reports a *played*
44+
library, so `watchlist` and `update_collection` have nothing to act on and are
45+
rejected up front rather than silently treated as `new`
46+
(`SUPPORTED_MODES` in `src/integrations/imports/xbox.py:40`).
47+
48+
### What "owned" means here
49+
50+
Xbox exposes no purchase library. Floppy approximates it with the union of
51+
`titleHistory` and the per-player achievement list — everything the account has
52+
launched. Non-game titles (Netflix, Twitch, the Store) are dropped before any
53+
IGDB lookup, and not every title publishes `MinutesPlayed`, so some games import
54+
with unknown playtime rather than zero.
55+
56+
## Rate limits and request budget
57+
58+
### OpenXBL's limits (per API key)
59+
60+
From [xbl.io/pricing](https://xbl.io/pricing):
61+
62+
| Plan | Price | Rate limit |
63+
| --- | --- | --- |
64+
| Free | $0/forever | 150 requests/hour |
65+
| Small | $5/month | 500 requests/hour |
66+
| Medium | $15/month | 2,500 requests/hour |
67+
| Large | $35/month | 5,000 requests/hour |
68+
| Enterprise | Custom | Custom |
69+
70+
Exceeding the limit returns HTTP 429; OpenXBL also returns rate-limit detail in
71+
response headers (`X-RateLimit-Remaining`).
72+
73+
### What one sync actually costs
74+
75+
Per import (`XboxImporter.import_data` in `src/integrations/imports/xbox.py:192`):
76+
77+
- 0 requests to resolve the XUID — it is stored at connect time. Only a blank
78+
`xuid` costs one `GET /account`.
79+
- 2 requests for the library: `GET /achievements/player/{xuid}` and
80+
`GET /player/titleHistory/{xuid}`.
81+
- `ceil(titles / 100)` requests for playtime: `POST /player/stats` batched at
82+
`STATS_BATCH_SIZE = 100` (`src/integrations/xbox_api.py:35`).
83+
84+
A 300-game library is **5 OpenXBL requests per sync**, plus 1 at connect. Even a
85+
daily schedule on a large library sits far under the free tier's 150/hour. The
86+
free plan is the right recommendation; nothing in Floppy's usage justifies a
87+
paid tier.
88+
89+
The slow, expensive part of an Xbox import is IGDB, not OpenXBL: each unmatched
90+
title costs up to three IGDB searches
91+
(`_search_names` in `src/integrations/imports/xbox.py:88`) against a
92+
3 req/sec budget.
93+
94+
### Floppy's own throttle
95+
96+
`https://xbl.io/api` is mounted with `LimiterAdapter(per_hour=120)`
97+
(`src/app/providers/services.py:295`) — deliberate
98+
headroom under the free tier's 150 so connect calls and retries can't tip a user
99+
over. Two caveats worth knowing before trusting it as a guarantee:
100+
101+
- **It is per process, not per key.** The per-host adapters are in-memory, one
102+
bucket per worker process (`src/app/providers/services.py:245`).
103+
Multiple Celery workers each carry their own 120/hour allowance, so the local
104+
ceiling does not strictly bound one key's hourly usage.
105+
- **It is per host, not per user.** All users' xbl.io traffic shares that single
106+
bucket, while OpenXBL counts per key. On a busy multi-user instance the local
107+
limiter can throttle imports whose keys still have quota to spare.
108+
109+
Given the real request volume above, neither caveat bites in practice.
110+
111+
### When a 429 does happen
112+
113+
`api_request` retries up to 3 times, sleeping `Retry-After + 3s` clamped to
114+
1–60 seconds (5s when the header is absent or unparseable)
115+
(`src/app/providers/services.py:499`). If it still
116+
fails, the importer marks the account broken with *"OpenXBL rate limit exceeded.
117+
Please try again later."*
118+
119+
Note the user-visible consequence: `is_connected` is `api_key AND NOT
120+
connection_broken`, so a transient rate-limit failure flips the Xbox badge to
121+
**Disconnected** and surfaces the error in the modal even though the key is
122+
fine. The stored key is untouched, the recurring schedule is not disabled, and
123+
the next successful sync clears both flags
124+
(`_mark_synced` in `src/integrations/imports/xbox.py:305`) — so it self-heals
125+
on the next run without user action.
126+
127+
## Privacy and request-log retention
128+
129+
### What Floppy stores
130+
131+
`XboxAccount` in `src/integrations/models.py:768` holds:
132+
133+
| Field | Contents |
134+
| --- | --- |
135+
| `api_key` | The OpenXBL key, Fernet-encrypted |
136+
| `xuid`, `gamertag` | From `GET /account` at connect time |
137+
| `last_sync_at` | Last successful sync |
138+
| `connection_broken`, `last_error_message` | Failure state shown in the import modal |
139+
140+
The Fernet key is derived from Django's `SECRET_KEY`
141+
(SHA-256 → urlsafe base64, `src/integrations/imports/helpers.py:534`).
142+
**Rotating `SECRET` makes every stored key undecryptable**: the next sync fails
143+
with "Stored credentials could not be decrypted…", marks the account broken, and
144+
the user must paste the key again. It does not corrupt anything else.
145+
146+
### What leaves the instance
147+
148+
- To OpenXBL: the API key in the `X-Authorization` header, and the XUID in the
149+
request path.
150+
- To IGDB: title names only, for matching. No Xbox identifiers, no XUID.
151+
152+
### Keeping the key out of logs and the UI
153+
154+
`last_error_message` is rendered on the import page and persisted until the next
155+
successful sync, so what lands there is constructed rather than stringified:
156+
157+
- HTTP failures are mapped to a message chosen from the **status code alone**
158+
(`_http_error_message` in `src/integrations/xbox_api.py:78`) — the raw
159+
`HTTPError` string would embed the request URL and response body.
160+
- Anything unexpected is reduced to `TypeName(status=…)` by `exception_summary`.
161+
- Whatever remains is run through `redact_secrets` and capped at 500 characters
162+
(`_safe_message` in `src/integrations/imports/xbox.py:80`).
163+
164+
One limitation to be aware of when changing this code: `redact_secrets` matches
165+
parameter names like `api_key` and `token`, but **not** `x-authorization`
166+
(`src/app/log_safety.py:18`). The key stays out of
167+
logs because no code path formats a raw exception, request, or header — not
168+
because the scrubber would catch it. Preserve that discipline.
169+
170+
### What OpenXBL stores and logs
171+
172+
From [xbl.io/agreement](https://xbl.io/agreement) (Terms of Service & Privacy
173+
Policy):
174+
175+
- Stores "Xbox Live profile information (gamertag, XUID, avatar)", "Email and
176+
phone number for account verification", and "Payment information (processed
177+
securely by Stripe)".
178+
- **"Request logs are retained for billing and debugging purposes."** No
179+
retention period is published. "Request logs" is listed as a feature on every
180+
plan including Free, and the logs are visible in the OpenXBL console.
181+
- "We do not sell your personal data."
182+
- "You may request deletion of your account and associated data at any time" —
183+
by contacting OpenXBL support.
184+
- "Your API keys are for your use only and should not be shared"; sharing or
185+
reselling access and circumventing rate limits are prohibited.
186+
187+
Because the title and stats endpoints are keyed by XUID in the path, OpenXBL's
188+
request logs necessarily record which XUID was queried and when — i.e. a user's
189+
sync schedule and library-fetch cadence are visible to OpenXBL for as long as it
190+
keeps those logs. Floppy cannot shorten or delete them; only OpenXBL can.
191+
192+
There is no way to point the integration at a different gateway:
193+
`XBOX_API_BASE_URL` in `src/integrations/xbox_api.py:28` is a constant, not a
194+
setting. Self-hosting Floppy does not avoid the third party here.
195+
196+
## Disconnect behavior
197+
198+
`POST /import/xbox/disconnect` (`xbox_disconnect` in `src/integrations/views.py:1949`)
199+
does exactly two things:
200+
201+
1. Deletes every `PeriodicTask` named `Import from Xbox (Recurring)` whose
202+
`kwargs` carry this user's `user_id` — matched on the exact id, tolerant of
203+
JSON quoting/spacing (`_plex_watchlist_task_filter` in `src/integrations/views.py:137`).
204+
Other users' Xbox schedules are untouched.
205+
2. Deletes the user's `XboxAccount` row, and with it the encrypted key, XUID,
206+
gamertag, and sync state.
207+
208+
It explicitly does **not**:
209+
210+
- Delete imported games, playtime, history, or `Item` metadata. Everything
211+
already imported stays in the library and keeps its "Imported from Xbox" note.
212+
- Revoke, delete, or notify anything at OpenXBL. The key remains valid on
213+
xbl.io. A user who wants it dead must regenerate or delete it in the OpenXBL
214+
console — worth saying out loud in any user-facing copy, since "Disconnect"
215+
reads like it revokes access.
216+
- Cancel an import already queued or running. A queued sync that starts after
217+
the row is gone fails with "Connect Xbox before importing", and one already in
218+
flight fails when it tries to write its result — in both cases there is no
219+
account row left to record the failure on, only the import history entry.
220+
221+
### Reconnecting
222+
223+
Connect again with the same or a new key. `update_or_create` refreshes the key,
224+
XUID, and gamertag and clears `connection_broken` / `last_error_message`, so a
225+
broken connection is repaired by reconnecting rather than needing a reset.
226+
227+
Re-importing does **not** resurrect games the user deleted in Floppy: Xbox keeps
228+
reporting a title forever once launched, so deleted media is checked and skipped
229+
on every run (`src/integrations/imports/xbox.py:380`).
230+
231+
### Rotating the key at OpenXBL
232+
233+
Regenerating the key on xbl.io does not tell Floppy. The stored key starts
234+
returning 401/402, the account is marked broken with "Invalid or expired OpenXBL
235+
API key. Reconnect your Xbox account.", and the schedule keeps firing and
236+
failing until the user pastes the new key.
237+
238+
### Deleting a Floppy user
239+
240+
`XboxAccount.user` is a `OneToOneField(on_delete=CASCADE)`, so the account row
241+
goes with the user. The recurring `PeriodicTask` does **not** — it holds
242+
`user_id` in `kwargs`, not as a foreign key, and no delete-time cleanup exists.
243+
An orphaned schedule keeps firing and raises `User.DoesNotExist` in
244+
`import_media` (`src/integrations/tasks/_media_imports.py:67`) each time. It is
245+
only cleared when a new schedule tries to claim the same name
246+
(`_reclaim_xbox_schedule_name` in `src/integrations/views.py:1786`). Disconnect
247+
before deleting a user, or remove the task in the beat admin.

src/app/detail_builders.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,13 @@ def _apply_cached_hltb_link(media_metadata, detail_item):
757757
"accent_classes": "text-slate-100",
758758
"fallback_text": "STM",
759759
},
760+
"xbox": {
761+
"logo_src": static("img/xbox-logo.svg"),
762+
"chip_classes": "border-green-400/18 bg-green-500/[0.07]",
763+
"badge_classes": "border-green-400/28 bg-green-500/14",
764+
"accent_classes": "text-green-100",
765+
"fallback_text": "XBX",
766+
},
760767
"plex": {
761768
"logo_src": static("img/plex-logo.svg"),
762769
"chip_classes": "border-amber-400/18 bg-amber-500/[0.07]",

src/app/providers/igdb.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
logger = logging.getLogger(__name__)
1818
base_url = "https://api.igdb.com/v4"
19-
IGDB_SEARCH_CACHE_VERSION = "v2"
19+
IGDB_SEARCH_CACHE_VERSION = "v4"
2020
TOKENIZED_SEARCH_MIN_TERMS = 2
2121

2222

@@ -270,12 +270,18 @@ def _build_search_query_condition(query, *, tokenized=False):
270270
terms = _tokenize_search_query(query)
271271
if len(terms) < TOKENIZED_SEARCH_MIN_TERMS:
272272
return None
273-
return " & ".join(f'name ~ *"{term}"*' for term in terms)
273+
return " & ".join(
274+
f'(name ~ *"{term}"* | alternative_names.name ~ *"{term}"*)'
275+
for term in terms
276+
)
274277

275278
escaped_query = str(query or "").strip().replace("\\", "\\\\").replace('"', '\\"')
276279
if not escaped_query:
277280
return None
278-
return f'name ~ *"{escaped_query}"*'
281+
return (
282+
f'(name ~ *"{escaped_query}"* '
283+
f'| alternative_names.name ~ *"{escaped_query}"*)'
284+
)
279285

280286

281287
def _build_search_multiquery(query, page, *, tokenized=False):
@@ -284,7 +290,7 @@ def _build_search_multiquery(query, page, *, tokenized=False):
284290
if not search_condition:
285291
return None
286292

287-
conditions = [search_condition, "game_type = (0,1,2,3,4,5,6,7,8,9,10)"]
293+
conditions = [search_condition, "game_type = (0,1,2,3,4,5,6,7,8,9,10,11)"]
288294
if not settings.IGDB_NSFW:
289295
conditions.append("themes != (42)")
290296

src/app/providers/services.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@ def get_process_role():
292292
"https://boardgamegeek.com/xmlapi2",
293293
LimiterAdapter(per_second=2),
294294
)
295+
session.mount(
296+
"https://xbl.io/api",
297+
LimiterAdapter(per_hour=120),
298+
)
295299

296300

297301
class ProviderAPIError(Exception):

0 commit comments

Comments
 (0)