Prefer Scryfall's representative printing for default card images

Card previews were picking whichever printing happened to load first
during ingest (ORDER BY rowid over every printing, promos and foreign
versions included), so a card's default image was effectively random.
Capture the representative printing's images straight from the Oracle
Cards bulk pass instead, and prefer that over the old printings-table
guess, falling back gracefully on an older reference.db that predates
these columns.

Also add docs/printing-tracking-design.md, a design (not yet
implemented) for optionally tracking which specific printing/finish a
library or deck card is.
This commit is contained in:
2026-07-22 22:05:44 -05:00
parent aaf632a417
commit d3e8a8f25a
10 changed files with 331 additions and 16 deletions

View File

@@ -412,6 +412,12 @@ where
#[derive(Deserialize)]
struct ScryOracle {
/// This entry's own printing id — the "representative" printing Scryfall
/// includes in the Oracle Cards bulk (its own doc: "the most up-to-date
/// recognizable" version). Captured as `oracle_cards.default_scryfall_id`
/// purely for reference; not currently read back.
#[serde(default)]
id: Option<String>,
oracle_id: Option<String>,
name: String,
#[serde(default)]
@@ -429,6 +435,8 @@ struct ScryOracle {
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
image_uris: Option<ImageUris>,
#[serde(default)]
card_faces: Option<Vec<ScryFace>>,
/// What colors of mana this card can add (lands, mana rocks, dorks). A
/// top-level-only Scryfall field — not per-face, even on DFCs.
@@ -452,6 +460,8 @@ struct ScryFace {
colors: Option<Vec<String>>,
#[serde(default)]
oracle_text: Option<String>,
#[serde(default)]
image_uris: Option<ImageUris>,
}
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
@@ -461,8 +471,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
let mut ins_card = conn.prepare(
"INSERT OR IGNORE INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal,
image_uri,back_image_uri,default_scryfall_id)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
@@ -560,6 +571,31 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
.any(|w| w == "Battle");
let horizontal = is_battle || c.layout == "planar";
// Representative-printing images: top-level for single-faced
// cards, else the front/back faces — same fallback shape as
// `load_printings` below, since Oracle Cards entries mirror a
// printing's image fields.
let image_uri = c
.image_uris
.as_ref()
.and_then(|i| i.normal.clone())
.or_else(|| {
c.card_faces
.as_ref()
.and_then(|f| f.first())
.and_then(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone())
})
.unwrap_or_default();
let back_image_uri = c
.card_faces
.as_ref()
.and_then(|f| f.get(1))
.and_then(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone())
.unwrap_or_default();
let default_scryfall_id = c.id.clone().unwrap_or_default();
let changed = ins_card.execute(params![
oracle_id,
c.name,
@@ -575,6 +611,9 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
themes,
c.edhrec_rank,
horizontal as i64,
image_uri,
back_image_uri,
default_scryfall_id,
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
@@ -1072,6 +1111,59 @@ mod tests {
assert!(!horizontal("oid-normal"));
}
#[test]
fn captures_representative_printing_image_from_oracle_cards() {
// Oracle Cards entries carry Scryfall's own pick of a recognizable
// printing's images — top-level for single-faced cards, per-face for
// DFCs (mirrors how `load_printings` reads a `printings` entry).
let oracle = r#"[
{"id":"scry-normal","oracle_id":"oid-normal","name":"Grizzly Bears",
"layout":"normal","cmc":2.0,"type_line":"Creature — Bear",
"color_identity":["G"],"keywords":[],
"image_uris":{"normal":"http://img/normal"}},
{"id":"scry-dfc","oracle_id":"oid-dfc","name":"Delver of Secrets // Insectile Aberration",
"layout":"transform","cmc":1.0,"color_identity":["U"],"keywords":[],
"card_faces":[
{"type_line":"Creature — Human Wizard","image_uris":{"normal":"http://img/front"}},
{"type_line":"Creature — Human Insect","image_uris":{"normal":"http://img/back"}}
]},
{"oracle_id":"oid-noimg","name":"No Image Test","layout":"normal","cmc":0.0,
"type_line":"Land","color_identity":[],"keywords":[]}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
let images = |id: &str| -> (String, String, String) {
conn.query_row(
"SELECT image_uri, back_image_uri, default_scryfall_id FROM oracle_cards WHERE oracle_id=?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap()
};
assert_eq!(
images("oid-normal"),
(
"http://img/normal".to_string(),
"".to_string(),
"scry-normal".to_string()
)
);
assert_eq!(
images("oid-dfc"),
(
"http://img/front".to_string(),
"http://img/back".to_string(),
"scry-dfc".to_string()
)
);
assert_eq!(images("oid-noimg"), ("".to_string(), "".to_string(), "".to_string()));
}
#[test]
fn captures_back_face_image_for_double_faced_cards() {
// Transform/MDFC cards: two `card_faces`, each with its own image.