|
| 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. |
0 commit comments