Files
MTGcompanion/main/scryfall_client.c
Rasmus 794ddc9b8f
All checks were successful
ESP32 Build & Release / build (push) Successful in 1m14s
fix(art): preserve uncropped card art aspect ratio using fit=inside
2026-09-12 18:57:22 +02:00

570 lines
18 KiB
C

/*
* MTG RFID Companion - Scryfall API client (Core 0)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "cJSON.h"
#include "wifi_manager.h"
#include "storage_manager.h"
#include "rfid_manager.h"
#include "scryfall_client.h"
static const char *TAG = "scryfall";
#define NET_TASK_STACK (10 * 1024)
#define NET_TASK_PRIO (3)
#define NET_CORE (0)
#define FETCH_QUEUE_LEN (8)
#define HTTP_TIMEOUT_MS (20000)
#define MAX_ATTEMPTS (2)
#define JSON_BUF_MAX (128 * 1024)
#define CARD_NAME_MAX (128)
#define UID_MAX (24)
#define FIELD_STR_MAX (256)
#define ORACLE_MAX (768)
#define IMAGE_URL_MAX (512)
#define EVT_CARD_CACHED (1U << 0)
#define EVT_CARD_FAILED (1U << 1)
typedef struct {
char name[CARD_NAME_MAX];
char uid[UID_MAX];
} fetch_request_t;
static QueueHandle_t s_fetch_queue;
static TaskHandle_t s_net_task;
static volatile uint32_t s_result_bits = 0;
static char s_last_uid[UID_MAX] = {0};
/* ------------------------------------------------------------------ */
/* URL helpers */
/* ------------------------------------------------------------------ */
static char to_hex_digit(unsigned int v)
{
return (char)(v < 10 ? '0' + v : 'A' + (v - 10));
}
static void url_encode(const char *in, char *out, size_t out_cap)
{
size_t o = 0;
for (size_t i = 0; in[i] != '\0' && o < out_cap - 3; i++) {
unsigned char c = (unsigned char)in[i];
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
|| c == '-' || c == '_' || c == '.') {
out[o++] = (char)c;
} else if (c == ' ') {
out[o++] = '+';
} else {
out[o++] = '%';
out[o++] = to_hex_digit((c >> 4) & 0x0F);
out[o++] = to_hex_digit(c & 0x0F);
}
}
out[o] = '\0';
}
/* ------------------------------------------------------------------ */
/* HTTP primitives (blocking, net_task only) */
/* ------------------------------------------------------------------ */
typedef struct {
char *buf;
size_t len;
size_t cap;
esp_err_t err;
FILE *file; /* when downloading to a file */
const char *file_path;
int status;
} http_sink_t;
static esp_err_t http_on_body(http_sink_t *sink, const char *data, size_t len)
{
if (sink->file != NULL) {
if (len > 0 && fwrite(data, 1, len, sink->file) != len) {
return ESP_FAIL;
}
return ESP_OK;
}
/* Reserve space for data plus 1 byte for null terminator */
if (len + 1 > sink->cap - sink->len) {
size_t req = sink->len + len + 1;
if (req > JSON_BUF_MAX) {
sink->err = ESP_ERR_NO_MEM;
return ESP_FAIL;
}
size_t new_cap = sink->cap ? sink->cap : 4096;
while (new_cap < req) {
new_cap *= 2;
}
char *nb = realloc(sink->buf, new_cap);
if (nb == NULL) {
sink->err = ESP_ERR_NO_MEM;
return ESP_FAIL;
}
sink->buf = nb;
sink->cap = new_cap;
}
memcpy(sink->buf + sink->len, data, len);
sink->len += len;
return ESP_OK;
}
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
http_sink_t *sink = (http_sink_t *)evt->user_data;
if (sink == NULL) {
return ESP_OK;
}
switch (evt->event_id) {
case HTTP_EVENT_ON_DATA:
return http_on_body(sink, (const char *)evt->data, evt->data_len);
case HTTP_EVENT_DISCONNECTED:
if (sink->file != NULL) {
fflush(sink->file);
}
break;
case HTTP_EVENT_ERROR:
sink->err = ESP_FAIL;
break;
default:
break;
}
return ESP_OK;
}
static esp_err_t http_request(const char *url, http_sink_t *sink)
{
if (wifi_manager_acquire_net_lock(pdMS_TO_TICKS(15000)) != ESP_OK) {
ESP_LOGW(TAG, "Network lock busy (e.g. OTA update in progress), deferring fetch %s", url);
return ESP_ERR_TIMEOUT;
}
/* Disable power save during HTTPS requests for maximum reliability */
wifi_manager_set_power_save(false);
esp_err_t overall_ret = ESP_FAIL;
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
if (sink->file != NULL) {
if (attempt > 0 && sink->file_path != NULL) {
fclose(sink->file);
sink->file = fopen(sink->file_path, "wb");
if (sink->file == NULL) {
overall_ret = ESP_FAIL;
break;
}
} else {
rewind(sink->file);
}
} else {
sink->len = 0;
}
sink->err = ESP_OK;
esp_http_client_config_t cfg = {
.url = url,
.transport_type = HTTP_TRANSPORT_OVER_SSL,
.timeout_ms = HTTP_TIMEOUT_MS,
.event_handler = http_event_handler,
.user_data = sink,
.buffer_size = 2048,
.crt_bundle_attach = esp_crt_bundle_attach,
.keep_alive_enable = false,
};
esp_http_client_handle_t client = esp_http_client_init(&cfg);
if (client == NULL) {
overall_ret = ESP_ERR_NO_MEM;
break;
}
esp_http_client_set_method(client, HTTP_METHOD_GET);
esp_http_client_set_header(client, "User-Agent", "MTGCompanion/1.0");
esp_http_client_set_header(client, "Accept", "*/*");
esp_err_t ret = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
esp_http_client_cleanup(client);
ESP_LOGD(TAG, "GET %s -> %s (status %d)", url, esp_err_to_name(ret), status);
if (ret == ESP_OK && status >= 200 && status < 300) {
overall_ret = ESP_OK;
break;
}
if (ret == ESP_OK && (status < 200 || status >= 300)) {
ESP_LOGW(TAG, "HTTP GET returned status %d for %s", status, url);
}
if (sink->err != ESP_OK) {
overall_ret = sink->err;
break;
}
vTaskDelay(pdMS_TO_TICKS(500));
}
wifi_manager_set_power_save(true);
wifi_manager_release_net_lock();
return overall_ret;
}
static esp_err_t http_get_json(const char *url, char **out_buf)
{
*out_buf = NULL;
http_sink_t sink = {0};
esp_err_t ret = http_request(url, &sink);
if (ret != ESP_OK || sink.buf == NULL) {
free(sink.buf);
ESP_LOGE(TAG, "JSON fetch failed: %s", url);
return ret;
}
sink.buf[sink.len] = '\0';
*out_buf = sink.buf;
ESP_LOGI(TAG, "fetched %d bytes from %s", (int)sink.len, url);
return ESP_OK;
}
static esp_err_t http_download_file(const char *url, const char *path)
{
char tmp[128];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE *f = fopen(tmp, "wb");
if (f == NULL) {
return ESP_FAIL;
}
http_sink_t sink = { .file = f, .file_path = tmp };
esp_err_t ret = http_request(url, &sink);
fclose(f);
if (ret != ESP_OK) {
remove(tmp);
return ret;
}
remove(path);
if (rename(tmp, path) != 0) {
remove(tmp);
return ESP_FAIL;
}
return ESP_OK;
}
static const char *get_string(const cJSON *item, const char *fallback)
{
if (item == NULL || !cJSON_IsString(item) || item->valuestring == NULL) {
return fallback;
}
return item->valuestring;
}
/* ------------------------------------------------------------------ */
/* Cache metadata */
/* ------------------------------------------------------------------ */
static esp_err_t scryfall_do_fetch(const char *card_name, const char *uid)
{
char json_path[64];
snprintf(json_path, sizeof(json_path), STORAGE_CARDS_DIR "/%s.json", uid);
char jpg_path[64];
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
if (storage_file_exists(json_path)) {
char *chk = NULL;
size_t chk_len = 0;
bool valid = false;
if (storage_read_text(json_path, &chk, &chk_len) == ESP_OK && chk != NULL) {
cJSON *cj = cJSON_Parse(chk);
if (cj) {
cJSON *n = cJSON_GetObjectItem(cj, "name");
cJSON *t = cJSON_GetObjectItem(cj, "type");
cJSON *o = cJSON_GetObjectItem(cj, "oracle");
/* Verify name matches the requested card name */
if (n && n->valuestring && strcasecmp(n->valuestring, card_name) == 0) {
if ((t && t->valuestring && strlen(t->valuestring) > 0) ||
(o && o->valuestring && strlen(o->valuestring) > 0)) {
valid = true;
}
}
cJSON_Delete(cj);
}
free(chk);
}
if (valid) {
if (storage_file_exists(jpg_path)) {
ESP_LOGI(TAG, "cache hit %s and %s", json_path, jpg_path);
return ESP_OK;
}
ESP_LOGI(TAG, "json valid but %s missing, fetching art...", jpg_path);
} else {
ESP_LOGW(TAG, "cache entry %s is invalid or card mismatch (expected '%s'), re-fetching...", json_path, card_name);
remove(json_path);
remove(jpg_path);
}
}
/* 1. GET card JSON */
char url[512];
char enc[CARD_NAME_MAX * 3];
url_encode(card_name, enc, sizeof(enc));
snprintf(url, sizeof(url), SCRYFALL_API SCRYFALL_CARD_ARTS_URL "%s", enc);
char *body = NULL;
esp_err_t ret = http_get_json(url, &body);
if (ret != ESP_OK || body == NULL) {
free(body);
ESP_LOGE(TAG, "scryfall request failed for '%s'", card_name);
return ret != ESP_OK ? ret : ESP_FAIL;
}
cJSON *root = cJSON_Parse(body);
free(body);
if (root == NULL) {
ESP_LOGE(TAG, "JSON parse failed for '%s'", card_name);
return ESP_FAIL;
}
cJSON *obj = cJSON_GetObjectItem(root, "object");
if (obj && cJSON_IsString(obj) && strcmp(obj->valuestring, "error") == 0) {
ESP_LOGE(TAG, "Scryfall API error: %s",
get_string(cJSON_GetObjectItem(root, "details"), "unknown error"));
cJSON_Delete(root);
return ESP_FAIL;
}
/* 2. Extract fields */
const char *name = get_string(cJSON_GetObjectItem(root, "name"), card_name);
const char *mana = NULL;
cJSON *mc = cJSON_GetObjectItem(root, "mana_cost");
if (cJSON_IsArray(mc) && cJSON_GetArraySize(mc) > 0) {
mana = get_string(cJSON_GetArrayItem(mc, 0), "");
} else {
mana = get_string(mc, "");
}
const char *type_line = get_string(cJSON_GetObjectItem(root, "type_line"), "");
const char *power = get_string(cJSON_GetObjectItem(root, "power"), "");
const char *toughness = get_string(cJSON_GetObjectItem(root, "toughness"), "");
const char *loyalty = get_string(cJSON_GetObjectItem(root, "loyalty"), "");
const char *oracle = get_string(cJSON_GetObjectItem(root, "oracle_text"), "");
const char *image_url = NULL;
cJSON *uris = cJSON_GetObjectItem(root, "image_uris");
cJSON *faces = cJSON_GetObjectItem(root, "card_faces");
if (faces && cJSON_IsArray(faces) && cJSON_GetArraySize(faces) > 0) {
cJSON *face0 = cJSON_GetArrayItem(faces, 0);
if (face0) {
if (uris == NULL) {
uris = cJSON_GetObjectItem(face0, "image_uris");
}
if (strlen(oracle) == 0) {
oracle = get_string(cJSON_GetObjectItem(face0, "oracle_text"), "");
}
if (strlen(mana) == 0) {
mana = get_string(cJSON_GetObjectItem(face0, "mana_cost"), "");
}
if (strlen(type_line) == 0) {
type_line = get_string(cJSON_GetObjectItem(face0, "type_line"), "");
}
}
}
if (uris) {
image_url = get_string(cJSON_GetObjectItem(uris, "art_crop"), "");
if (strlen(image_url) == 0) {
image_url = get_string(cJSON_GetObjectItem(uris, "normal"), "");
}
}
const char *price_usd = NULL;
cJSON *prices = cJSON_GetObjectItem(root, "prices");
if (prices) {
price_usd = get_string(cJSON_GetObjectItem(prices, "usd"), "");
}
const char *rarity = get_string(cJSON_GetObjectItem(root, "rarity"), "");
/* 3. Persist metadata with proper JSON escaping */
cJSON *card_obj = cJSON_CreateObject();
if (card_obj) {
cJSON_AddStringToObject(card_obj, "name", name);
cJSON_AddStringToObject(card_obj, "mana_cost", mana);
cJSON_AddStringToObject(card_obj, "type", type_line);
cJSON_AddStringToObject(card_obj, "rarity", rarity);
cJSON_AddStringToObject(card_obj, "power", power);
cJSON_AddStringToObject(card_obj, "toughness", toughness);
cJSON_AddStringToObject(card_obj, "loyalty", loyalty);
cJSON_AddStringToObject(card_obj, "oracle", oracle);
cJSON_AddStringToObject(card_obj, "price_usd", price_usd ? price_usd : "");
char *meta_str = cJSON_PrintUnformatted(card_obj);
cJSON_Delete(card_obj);
if (meta_str != NULL) {
ret = storage_write_text(json_path, meta_str);
free(meta_str);
} else {
ret = ESP_ERR_NO_MEM;
}
} else {
ret = ESP_ERR_NO_MEM;
}
/* 4. Download cover art (best effort) */
if (image_url && strlen(image_url) > 0) {
char jpg_path[64];
snprintf(jpg_path, sizeof(jpg_path), STORAGE_CARDS_DIR "/%s.jpg", uid);
char enc_img[512];
url_encode(image_url, enc_img, sizeof(enc_img));
char proxy_url[600];
/* wsrv.nl converts Scryfall's progressive JPEG to a lightweight baseline JPEG preserving original aspect ratio */
snprintf(proxy_url, sizeof(proxy_url), "https://wsrv.nl/?url=%s&w=200&h=150&fit=inside&output=jpg", enc_img);
esp_err_t dl = http_download_file(proxy_url, jpg_path);
if (dl != ESP_OK) {
ESP_LOGW(TAG, "proxied image download failed, trying direct: %s", image_url);
dl = http_download_file(image_url, jpg_path);
}
if (dl != ESP_OK) {
ESP_LOGW(TAG, "image download failed: %s", image_url);
} else {
ESP_LOGI(TAG, "image cached to %s", jpg_path);
}
}
cJSON_Delete(root);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "card '%s' cached under %s", card_name, uid);
}
return ret;
}
static esp_err_t scryfall_ensure_cached(const char *card_name, const char *uid)
{
esp_err_t ret = scryfall_do_fetch(card_name, uid);
snprintf(s_last_uid, sizeof(s_last_uid), "%s", uid);
s_result_bits = (ret == ESP_OK) ? EVT_CARD_CACHED : EVT_CARD_FAILED;
return ret;
}
/* ------------------------------------------------------------------ */
/* net_task */
/* ------------------------------------------------------------------ */
#ifdef CONFIG_MTG_SCAN_PREFETCH
static void prefetch_mappings(void)
{
/* Settle delay: give system and Wi-Fi time to settle before background fetch */
vTaskDelay(pdMS_TO_TICKS(5000));
if (esp_get_free_heap_size() < 40000) {
ESP_LOGW(TAG, "Heap too low (%u bytes) for startup prefetch, skipping", (unsigned int)esp_get_free_heap_size());
return;
}
char *buf = NULL;
size_t len = 0;
if (storage_read_text(STORAGE_MAPPINGS, &buf, &len) != ESP_OK || buf == NULL) {
free(buf);
return;
}
cJSON *root = cJSON_Parse(buf);
free(buf);
if (root == NULL || !cJSON_IsObject(root)) {
if (root) cJSON_Delete(root);
ESP_LOGW(TAG, "mappings.json unreadable");
return;
}
const char *uid;
cJSON *item = NULL;
cJSON_ArrayForEach(item, root) {
uid = item->string;
if (uid == NULL || item->valuestring == NULL) continue;
/* Call scryfall_do_fetch directly so we do not clobber UI fetch signaling */
scryfall_do_fetch(item->valuestring, uid);
/* Rate-limit delay: Scryfall requests 50-100ms between queries */
vTaskDelay(pdMS_TO_TICKS(150));
}
cJSON_Delete(root);
}
#endif
static void net_task(void *arg)
{
(void)arg;
ESP_LOGI(TAG, "net_task on core %d", xPortGetCoreID());
#ifndef CONFIG_MTG_WIFI_SSID
#error "Missing CONFIG_MTG_WIFI_SSID"
#endif
if (wifi_manager_sta_configured()) {
if (wifi_manager_wait_connected(pdMS_TO_TICKS(60000)) != ESP_OK) {
ESP_LOGW(TAG, "Wi-Fi connect timed out, continuing without network");
} else {
#ifdef CONFIG_MTG_SCAN_PREFETCH
prefetch_mappings();
#endif
}
}
while (1) {
fetch_request_t req;
BaseType_t got = xQueueReceive(s_fetch_queue, &req, portMAX_DELAY);
if (got == pdFALSE) {
continue;
}
scryfall_ensure_cached(req.name, req.uid);
}
}
esp_err_t scryfall_client_start(void)
{
if (s_fetch_queue == NULL) {
s_fetch_queue = xQueueCreate(FETCH_QUEUE_LEN, sizeof(fetch_request_t));
if (s_fetch_queue == NULL) {
return ESP_ERR_NO_MEM;
}
xTaskCreatePinnedToCore(net_task, "net_task", NET_TASK_STACK, NULL, NET_TASK_PRIO, &s_net_task, NET_CORE);
}
return ESP_OK;
}
esp_err_t scryfall_fetch_request(const char *card_name, const char *uid)
{
if (s_fetch_queue == NULL) {
return ESP_ERR_INVALID_STATE;
}
if (strlen(card_name) >= CARD_NAME_MAX || strlen(uid) >= UID_MAX || strlen(card_name) == 0) {
return ESP_ERR_INVALID_ARG;
}
fetch_request_t req;
snprintf(req.name, sizeof(req.name), "%s", card_name);
snprintf(req.uid, sizeof(req.uid), "%s", uid);
return xQueueSend(s_fetch_queue, &req, 0) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
}
bool scryfall_has_pending_result(void)
{
return (s_result_bits & (EVT_CARD_CACHED | EVT_CARD_FAILED)) != 0;
}
bool scryfall_last_result_success(void)
{
return (s_result_bits & EVT_CARD_CACHED) != 0;
}
const char *scryfall_last_result_uid(void)
{
return s_last_uid;
}
void scryfall_consume_result(void)
{
s_result_bits = 0;
}