Skip to content

Scryfall Integration

Pablo Martínez López edited this page Oct 20, 2026 · 3 revisions

Scryfall Integration

English · Español

All contact with Scryfall happens in Rust, in src-tauri/src/scryfall/. The module provides one HTTP client with rate limiting and retries, an in-memory search cache, a persistent card cache in SQLite, and the pipeline that turns a parsed decklist into cards with their images stored in the blob store. The web view never talks to the network.

Boundaries: the list syntax is Decklist-Parsing; non-Scryfall sources (Cockatrice tokens, other-language names) are External-Sources; linking a user's own image to a real card is Card-Identification-and-OCR.

Files

File Responsibility
src-tauri/src/scryfall/client.rs Scryfall (the only door to api.scryfall.com), trait CardApi, Simbolo.
src-tauri/src/scryfall/rate.rs Limitador: minimum spacing between API requests.
src-tauri/src/scryfall/busquedas.rs Busquedas: one-hour in-memory cache of search results, bounded by RAM.
src-tauri/src/scryfall/cache.rs guardar, leer, leer_impresion: persistent card cache (table scryfall_cards).
src-tauri/src/scryfall/models.rs ScryCard, CardFace, ImageUris, Identifier, Collection, SearchPage, SetInfo...
src-tauri/src/scryfall/resolve.rs resolve(): the decklist -> cards pipeline; trait Guardar; ResolveOptions, ImagePolicy.
src-tauri/src/scryfall/fichas_auto.rs Token detection through all_parts (fichas_de, faltan_en_el_deck).
src-tauri/src/scryfall/fichas.rs Spanish token type -> English token name table (used by OCR identification).
src-tauri/src/scryfall/nombres.rs Local catalogue of all card names and set codes (Indice, indice(), codigos_de_sets()).
src-tauri/src/commands/cards.rs Commands: resolve_decklist, job_cancel, card_autocomplete, card_search, card_prints, card_fetch, card_fetch_externa, deck_tokens_detect, sets_list, set_cards, import_archidekt.
src-tauri/src/state.rs AppState.scryfall: Arc<Scryfall> (one shared client, one shared rate limiter).

The client (client.rs)

Rules documented in the module header and implemented in Scryfall::enviar:

  • Rate limit. Every request to api.scryfall.com first awaits Limitador::turno(): a mutex-guarded timestamp; the caller sleeps until at least 100 ms (Duration::from_millis(100) in Scryfall::new) after the previous request. One limiter for the whole app, so concurrent jobs share the budget.
  • Images are different. Downloads (CardApi::download) skip the limiter (con_ritmo = false) because card images are served by Scryfall's CDN (*.scryfall.io), but they are bounded to 6 at a time by a semaphore (DESCARGAS_A_LA_VEZ).
  • Retries. HTTP 429 and any 5xx are retried up to MAX_REINTENTOS = 3 times, waiting 0.5 s, 1 s, 2 s, or the Retry-After header (seconds form) when present. After the last retry: 429 -> ErrorCode::RateLimited; other failures -> ErrorCode::Network with estado.
  • 404 is not an error. enviar returns None; json() turns that into Ok(None) and the callers decide. This is why a search with zero results (Scryfall answers 404) yields an empty list, and why card() returns Option<ScryCard>.
  • Timeouts. 30 s total, 10 s connect. User agent ProxyBuilder/<version>.
  • Audit log. Every real request is logged with tracing (metodo, host, ruta, estado, message "red"), so one can check that opening a saved deck downloads nothing.
  • Testing seam. The resolver depends on the trait CardApi (collection, card, card_lang, search, download), so tests use a fake (Falso in resolve.rs tests) instead of the network.

Endpoints used

Method on Scryfall Scryfall endpoint Notes
collection(ids) POST /cards/collection Body {identifiers: [...]} where Identifier is Id, SetNumber, NameSet or Name (serialised untagged). Max 75 per call (LOTE).
card(id) GET /cards/{id} None on 404.
card_lang(set, number, lang) GET /cards/{set}/{number}/{lang} Path segments percent-encoded (urlencode; collector numbers can contain ★ or -).
search(q, prints, limit) GET /cards/search prints = true adds unique=prints&include_multilingual=true. Follows next_page until limit (capped to TOPE_BUSQUEDA = 175).
search_multilingual(q, limit) GET /cards/search include_multilingual=true only (one result per card, any language); no 175 cap.
autocomplete(q) GET /cards/autocomplete?include_extras=true The command ignores queries shorter than 2 characters.
card_names() GET /catalog/card-names Feeds the local name index.
sets() GET /sets Set browser and set-code recognition; mapped to SetInfo.
symbology() GET /symbology Mana symbol SVGs for the designer.
download(url) any image URL Bytes only; the caller stores them.

Search syntax

The app passes the user's text straight to Scryfall's search, so all Scryfall syntax works in "Add card" (t:goblin, set:neo, o:"draw a card"...). commands/cards.rs adds to it:

  • consulta_busqueda: <q> lang:<code> when a language other than any is chosen, plus is:hires for high-resolution only.
  • consulta_impresiones (variants / prints): !"<exact name>" + the same filters. Double quotes inside the name are removed so they cannot close the quote early.
  • consulta_set: e:<code> plus optional t:<type>; both are filtered to alphanumerics/letters so the set browser cannot inject extra query syntax.
  • The identify flow appends include:extras -layout:art_series -layout:front_card (tokens are hidden by Scryfall without include:extras; art cards and Jumpstart front cards are noise).

card_search and card_prints also append results from the Cockatrice token catalogue when the language is en/unset and high-res-only is off (see External-Sources): card_search puts them after Scryfall's results, or first if one is named exactly like the query and Scryfall has none; card_prints appends them. card_prints swallows search errors (unwrap_or_default) because a token Scryfall does not have is not a failure.

Caches

Cache Where Lifetime Purpose
Search results memory, busquedas.rs Busquedas 1 hour (VIDA_BUSQUEDA) Reopening a dialog or searching the same card in several lists does not repeat the request. Key = query + extra params + limit. Capped to cartas_segun_memoria() cards (0.25 % of total RAM / 4 KB per card, clamped 5 000..50 000); oldest entries are evicted first, and a result larger than the cap is not stored. Short life on purpose: results may end up in the persistent cache stamped "now", and a long-lived entry would make old prices look fresh.
Card data SQLite table scryfall_cards (id, oracle_id, name, lang, set_code, collector_number, json, fetched_at; indexes by name+lang and set+number+lang), written by cache::guardar TTL_MS = 7 days for gameplay data; prices are refreshed separately after 24 h by commands/stats.rs Lets statistics, "link" and the designer work offline. The row stores the whole card JSON.
Names / set codes files in app_cache_dir 7 days See "Names catalogue" below.

cache::leer_impresion finds a printing by set + number + language (latest fetched_at). Fields added to ScryCard over time are Options where None means "cached before the app read this field" (keywords, full_art, oversized); ScryCard::completa_para_disenar() and the stats freshness check use them to refetch old rows.

Resolving a list (resolve.rs)

resolve(api, guardar, lista, opciones, cancelado, progreso) is generic over CardApi and Guardar. Guardar is the injection point for everything that is not Scryfall: guardar (store image bytes in the blob store, EnLaApp in commands/cards.rs calls blobs.put_bytes), apuntar (write the card into scryfall_cards), nombre_ingles (other-language name -> English, via External-Sources), externa_por_nombre and externas_creadas_por (Cockatrice tokens). The defaults are no-ops, so tests can implement only guardar.

ResolveOptions { lang, image_policy, add_tokens }:

  • lang: a Scryfall language code (en, es...) or any/empty for "best available".
  • ImagePolicy: BestAvailable (any card with an image URI), WithImage (also rejects image_status missing/placeholder; the UI default), HighresOnly (also requires highres_image).
  • add_tokens: also add tokens the cards create.
flowchart TD
    A["ParsedDecklist"] --> B["filter Card and included<br/>distinct Peticion (name, set, collector)"]
    B --> C["1. POST /cards/collection<br/>batches of 75, match by casa()"]
    C --> D{"unmatched and no set?"}
    D -- yes --> E["1b. nombre_ingles() max 20<br/>then collection by English name"]
    D -- no --> F
    E --> F["2-3. per found card"]
    F --> G{"wants other language?"}
    G -- yes --> H["en_idioma(): card_lang then search lang:xx"]
    G -- no --> I
    H --> I["segun_politica(): else search other printing"]
    I -- none --> U1["Unresolved NoImage"]
    I -- ok --> J["finales (card, lang_fallback)"]
    J --> K{"add_tokens?"}
    K -- yes --> L["4. fichas_de() on English printings"]
    K -- no --> M
    L --> M["5. apuntar() to cache<br/>download PNG per distinct card"]
    M --> N["6. build DeckCard copies in list order"]
    N --> O{"card missing on Scryfall?"}
    O -- yes --> P["externa_por_nombre() Cockatrice"]
    P -- none --> U2["Unresolved NotFound"]
    N --> Q["append tokens, then external tokens"]
    Q --> R["ResolveResult { cards, unresolved }"]
Loading

Step by step

  1. Input filter. Only lines with kind == Card && included are used. Lines are grouped into distinct Peticion { name, set, collector }; repeated lines resolve once and are copied qty times at the end.
  2. Lookup. Each Peticion becomes an Identifier: set + number -> SetNumber; set only -> NameSet; otherwise Name. For A // B names only the first face is sent (nombre_para_buscar), because /cards/collection does not find the full double-faced name. Requests go in batches of 75. Responses are matched with Peticion::casa (set and number case-insensitive, or ScryCard::responde_a(name): full name, printed name, or any face name) and never by position, because not_found entries shift the response order. Progress phase: Lookup.
  3. Other-language names (1b). Requests that found nothing and have no set may be written in another language ("Muro de algas"). The language tried is options.lang, or Spanish when that is empty, any or en. For at most 20 such lines (MAX_TRADUCIDAS), Guardar::nombre_ingles runs concurrently (the online fallback is slow); each hit is looked up again by English name and matched with responde_a.
  4. Language and image policy, per found card (progress phase Language):
    • If the user wants a language and the card's lang differs, en_idioma tries GET /cards/{set}/{number}/{lang} and, if that is missing or in another language, searches !"name" lang:xx over printings preferring the same set, then any set. If nothing exists the card stays as is.
    • segun_politica checks the policy. If the card fails it, all printings of that name are searched and the best passing one is chosen: same language and set, then same language, then any. If none passes, the request is NoImage.
    • The flag lang_fallback is quiere_idioma && final.lang != options.lang; it becomes CardMeta.lang_fallback, shown as the badge "back to EN".
    • For tokens, the original English printing seen in step 1 is remembered (inglesas) because translated printings carry incomplete all_parts.
  5. Tokens (add_tokens). From the English printing of each distinct card, fichas_de returns the tokens it creates (see below); they are appended at the end of the deck as CardSource::Token.
  6. Images. Every distinct card (and token) is written to scryfall_cards (apuntar), then downloaded in groups of 12 with join_all (the client semaphore keeps real concurrency at 6). ScryCard::print_url(face) picks the PNG (745x1040) when available, else large; the origin is recorded as ImageOrigin::ScryfallPng or ScryfallLarge. A double-faced card (is_dfc: no top-level image_uris and two faces with images) downloads face 1 too and becomes BackSpec::Dfc. Progress phase Images.
  7. Assembly, in list order. Each copy is a new DeckCard (fresh UUID, CardSource::Scryfall { scryfall_id }, CardMeta from meta_de). The line's section sets marca: Sideboard -> Banquillo, Maybeboard -> Opcional (see Decklist-Parsing). A request that Scryfall did not find is tried against Cockatrice by name (externa_por_nombre); if still nothing it is Unresolved { line_no, name, reason: NotFound }. A request filtered out by the policy is Unresolved ... NoImage. Tokens follow, then tokens from other sources created by the deck's cards (externas_creadas_por, de-duplicated by name against Scryfall's tokens).

Cancellation, events, busy state

resolve_decklist returns a job_id immediately and runs on the Tauri async runtime. Progress goes out on the event resolve://progress (ResolveProgressEvent { jobId, progress: { phase, done, total } }, phases lookup, language, tokens, images) and the end on resolve://done (ResolveDoneEvent { jobId, result, error }). state.jobs (src-tauri/src/jobs.rs) hands out ids and an AtomicBool cancel flag; job_cancel(jobId) sets it and the resolver checks it between batches (comprobar -> ErrorCode::Cancelled, in which case no cards are returned). The command holds a state.trabajo() guard, which makes the auto-updater refuse to run (Busy) while a job is running.

The front end uses lanzarTrabajo (src/lib/trabajos.ts), which listens before launching (a short job can finish before invoke returns its id) and buffers early events. src/features/decklist/acciones.ts resolver() builds the options from the UI store (lang, imagePolicy, addTokens), shows phase labels from the progress events, and treats Cancelled as "nothing changed". "Reapply language" (reaplicarIdioma) builds a synthetic ParsedDecklist with one line per non-locked Scryfall card (name + its current set and number) and calls the same pipeline with addTokens: false, then swaps images and metadata while keeping each card's id, edits, lock and custom back (aplicarReaplicado in src/lib/cartas.ts).

Variants and printings

  • card_prints(name, lang, highres_only) -> Vec<CardSummary>: all printings of an exact name (up to 175), used by the variant picker (variant-dialog.tsx) and by "Add card" in its exact-name mode (buscar(texto, exacto = true) in add-card-dialog.tsx; otherwise it calls card_search). lang: null means any language.
  • card_fetch(scryfall_id) -> DeckCard (traer_carta): fetch one printing, store it in the cache, download its PNG (and back face), and return a card with source Token if its layout is token/double_faced_token, else Scryfall. The caller inserts it or uses it to replace another card's image.
  • CardSummary is the light row for grids: ids, names, set, number, a thumbnail fingerprint (not a URL), highres, image_status, is_dfc, type_line, and fuente for non-Scryfall cards. Thumbnails: resumir() registers each image URL under a SHA-256 of the URL in AppState.miniaturas (cleared when it passes 5 000 entries), and the web view requests pbimg://.../t/<fingerprint>; the protocol handler (src-tauri/src/protocol.rs) resolves the fingerprint, downloads (Scryfall client for https://cards.scryfall.io/, otherwise the allow-listed Externo) and caches the derivative. See Live-Preview.
  • sets_list and set_cards(code, type_filter) feed Set-Browser.

Token detection (fichas_auto.rs)

Used twice: "add tokens" while resolving, and "Detect tokens" on an existing deck (deck_tokens_detect, UI in src/features/decklist/fichas-dialog.tsx).

  • Tokens are listed in a card's all_parts entries with component == "token" (excluding the card itself).
  • Why English printings only: Scryfall provides complete all_parts only on the English printing; translated printings have none or a partial list. So for every card not in English, fichas_de first fetches its English card by (front-face) name via /cards/collection and reads the parts there. If the English card cannot be found, the translated card's own parts are still used.
  • Token ids are then fetched in batches of 75 by id. Two tokens count as the same if their clave_de_ficha matches: the oracle id, or name|type_line|power/toughness when there is none, so the same token from different token sets appears once. Each result remembers which card created it (creada_por).
  • faltan_en_el_deck(api, deck, de_la_cache, cancelado) additionally removes tokens the deck already has: same id, same token from another set, or a token with the same name (also a custom card named like it). Cards come from the local cache when possible and from Scryfall otherwise. deck_tokens_detect then also adds Cockatrice tokens created by the deck's cards. It only proposes (FichaPropuesta { carta, creada_por }); the dialog adds the chosen ones with card_fetch.

Names catalogue (nombres.rs)

A local index of every card name (about 30 000 English names from /catalog/card-names) and every set code (/sets), used to recognise text read by OCR without network calls.

  • Files in app_cache_dir: scryfall-card-names.json, scryfall-set-codes.json. Refreshed when older than 7 days; if the download fails the stale copy is used; the cache can be deleted without losing anything. Loaded once per session into OnceCells (a failure is not remembered, so it retries next time).
  • Indice keeps two maps keyed by normalizar(name) (lower case, no accents or punctuation): exactos and plegados, where the OCR-confusable letters are folded (l, 1, | -> i; 0 -> o; plegar). Double-faced names are indexed whole and by each face.
  • Lookups: exacto(line), al_principio(line) (longest name the line starts with, two words or more so "Island of the dead" is not "Island"), and parecido(line) (best fuzzy match with similarity >= 0.85, scanning the whole catalogue, skipping candidates whose length differs by more than 15 %; slow, so it is used for a handful of lines).
  • Consumers: commands/identify.rs and originales.rs (see Card-Identification-and-OCR).

Failure modes and error codes

Condition Code Where
Offline, DNS, TLS, timeout Network (detalle) any call
429 after 3 retries RateLimited enviar
Other non-2xx, non-404 (e.g. 400 for a bad query) Network (estado) enviar
Card id not found NotFound (carta) card_fetch, card_link
Job cancelled Cancelled resolve
DB write fails Db cache::guardar
Per-line failures during resolve not an error: Unresolved entries in ResolveResult resolve

The whole resolve returns Err (and no cards) if a collection request or an image download fails; partial results exist only for name-level failures.

Tests

  • src-tauri/src/scryfall/resolve.rs (mod tests, with fake Falso API): four-card list, sideboard marks, DFC front/back, language fallback, high-res policy switching printing, tokens from the English printing, tokens appended once, cancellation, more than 75 lines in batches, progress completes, other-language name via fallback sources, Cockatrice fallbacks.
  • src-tauri/src/scryfall/{rate,busquedas,cache,fichas,nombres,fichas_auto,models}.rs: limiter timing (paused clock), cache eviction and expiry, cache round trip, token type table, fuzzy/exact name index, token de-duplication, model parsing (fixture fixtures/dusk-dawn-es.json).
  • src-tauri/src/commands/cards.rs (mod tests): query builders, thumbnail registry, traer_carta for normal/DFC/token/no-image/missing.
  • Live tests against the real API are #[ignore]d (for example scryfall_en_vivo in client.rs).

Where to start when extending

  • A new Scryfall endpoint: add it to Scryfall (use self.json(...) so rate limiting and retries apply); if the resolver or tests need it, add it to CardApi and to the fakes (Falso in resolve.rs, Api in commands/cards.rs tests, SinRed in commands/stats.rs).
  • A new pipeline step: keep the "match by identity, never by position" rule, report progress with a ResolvePhase, and call comprobar(cancelado) between network batches.
  • New card fields: add them to ScryCard as Option (so older cached JSON still deserialises) and decide whether old rows must be refetched.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally