mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 00:01:07 +00:00
minor: yugioh support added
This commit is contained in:
@@ -9,6 +9,7 @@ std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
}
|
||||
return "Magic";
|
||||
}
|
||||
@@ -51,6 +52,7 @@ std::string_view to_string(Theme t) noexcept {
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -83,8 +85,8 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::array<Game, 2>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 2> v{Game::Magic, Game::Pokemon};
|
||||
const std::array<Game, 3>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"rarity", c.rarity},
|
||||
{"rarityCode", c.rarityCode},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhCard& c) {
|
||||
j.at("id").get_to(c.id);
|
||||
j.at("amount").get_to(c.amount);
|
||||
j.at("name").get_to(c.name);
|
||||
j.at("set").get_to(c.set);
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("note").get_to(c.note);
|
||||
j.at("images").get_to(c.images);
|
||||
j.at("language").get_to(c.language);
|
||||
j.at("condition").get_to(c.condition);
|
||||
j.at("firstEdition").get_to(c.firstEdition);
|
||||
j.at("rarity").get_to(c.rarity);
|
||||
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
|
||||
else c.rarityCode.clear();
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -62,38 +62,47 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string> MagicCardPreviewSource::parseResponse(const std::string& body) {
|
||||
Result<std::string, PreviewLookupError>
|
||||
MagicCardPreviewSource::parseResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Scryfall response missing 'data' array.");
|
||||
// Treat schema deviation as transient: the API contract failed,
|
||||
// not the user's record. Scryfall returns a JSON error object
|
||||
// here on outage, which is rare but not stable.
|
||||
return R::err({K::Transient, "Scryfall response missing 'data' array."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Scryfall returned no matching cards.");
|
||||
return R::err({K::NotFound, "Scryfall returned no matching cards."});
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("image_uris") || !first.at("image_uris").is_object()) {
|
||||
// Double-faced cards expose image_uris on each face; there is no
|
||||
// fallback for this and surfaces it as "no preview".
|
||||
return Result<std::string>::err("Card has no top-level image_uris.");
|
||||
return R::err({K::NotFound, "Card has no top-level image_uris."});
|
||||
}
|
||||
const auto& uris = first.at("image_uris");
|
||||
if (!uris.contains("normal") || !uris.at("normal").is_string()) {
|
||||
return Result<std::string>::err("Card has no 'normal' image variant.");
|
||||
return R::err({K::NotFound, "Card has no 'normal' image variant."});
|
||||
}
|
||||
return Result<std::string>::ok(uris.at("normal").get<std::string>());
|
||||
return R::ok(uris.at("normal").get<std::string>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(std::string("Scryfall JSON parse error: ") + e.what());
|
||||
return R::err({K::Transient, std::string("Scryfall JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> MagicCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view /*setNo*/) {
|
||||
Result<std::string, PreviewLookupError>
|
||||
MagicCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view /*setNo*/) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
const std::string url = buildSearchUrl(name, setId);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
|
||||
@@ -70,40 +70,46 @@ std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::string>::err("Pokemon TCG response missing 'data' array.");
|
||||
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return Result<std::string>::err("Pokemon TCG returned no matching cards.");
|
||||
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("images") || !first.at("images").is_object()) {
|
||||
return Result<std::string>::err("Card has no 'images' object.");
|
||||
return R::err({K::NotFound, "Card has no 'images' object."});
|
||||
}
|
||||
const auto& images = first.at("images");
|
||||
if (images.contains("large") && images.at("large").is_string()) {
|
||||
return Result<std::string>::ok(images.at("large").get<std::string>());
|
||||
return R::ok(images.at("large").get<std::string>());
|
||||
}
|
||||
if (images.contains("small") && images.at("small").is_string()) {
|
||||
return Result<std::string>::ok(images.at("small").get<std::string>());
|
||||
return R::ok(images.at("small").get<std::string>());
|
||||
}
|
||||
return Result<std::string>::err("Card has no 'large' or 'small' image variant.");
|
||||
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
return R::err({K::Transient,
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
const std::string url = buildSearchUrl(name, setId, setNo);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return Result<std::string>::err(resp.error());
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
|
||||
// here so the YGO and Magic code paths can drift independently if the future
|
||||
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
|
||||
// for the `|` separator inside `titles=`).
|
||||
std::string urlEncode(std::string_view in) {
|
||||
std::ostringstream out;
|
||||
out.fill('0');
|
||||
out << std::hex << std::uppercase;
|
||||
for (unsigned char c : in) {
|
||||
const bool unreserved =
|
||||
(c >= 'A' && c <= 'Z') ||
|
||||
(c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '.' || c == '_' || c == '~';
|
||||
if (unreserved) {
|
||||
out << static_cast<char>(c);
|
||||
} else {
|
||||
out << '%';
|
||||
out.width(2);
|
||||
out << static_cast<unsigned int>(c);
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
|
||||
// always return card_images[0]: when no `cardset=` filter is applied, that
|
||||
// slot is the original/standard artwork (alt-art passcodes follow), which is
|
||||
// the closest fallback we have when Yugipedia has no scan for this printing.
|
||||
std::string imageFromCard(const nlohmann::json& card) {
|
||||
if (!card.contains("card_images") || !card.at("card_images").is_array() || card.at("card_images").empty()) {
|
||||
return {};
|
||||
}
|
||||
const auto& first = card.at("card_images").at(0);
|
||||
if (first.contains("image_url") && first.at("image_url").is_string()) {
|
||||
return first.at("image_url").get<std::string>();
|
||||
}
|
||||
if (first.contains("image_url_small") && first.at("image_url_small").is_string()) {
|
||||
return first.at("image_url_small").get<std::string>();
|
||||
}
|
||||
if (first.contains("image_url_cropped") && first.at("image_url_cropped").is_string()) {
|
||||
return first.at("image_url_cropped").get<std::string>();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Split a setNo encoded by the UI as `<setNo>||<rarity>||<edition>` into its
|
||||
// three positional fields. Any missing trailing field becomes an empty
|
||||
// string, so older callers that pass just `<setNo>` keep working.
|
||||
struct ParsedSetNo {
|
||||
std::string setNo;
|
||||
std::string rarity;
|
||||
std::string edition; // "1E" / "UE" / "" (unknown)
|
||||
};
|
||||
ParsedSetNo parseSetNoTuple(std::string_view raw) {
|
||||
std::string s(raw);
|
||||
ParsedSetNo p;
|
||||
const auto a = s.find("||");
|
||||
if (a == std::string::npos) {
|
||||
p.setNo = trim(std::move(s));
|
||||
return p;
|
||||
}
|
||||
p.setNo = trim(s.substr(0, a));
|
||||
std::string rest = s.substr(a + 2);
|
||||
const auto b = rest.find("||");
|
||||
if (b == std::string::npos) {
|
||||
p.rarity = trim(std::move(rest));
|
||||
return p;
|
||||
}
|
||||
p.rarity = trim(rest.substr(0, b));
|
||||
p.edition = trim(rest.substr(b + 2));
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhCardPreviewSource::YuGiOhCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
// ============================================================================
|
||||
// Yugipedia (image-preview path)
|
||||
// ============================================================================
|
||||
|
||||
std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
|
||||
// Yugipedia's image policy strips whitespace and a fixed set of
|
||||
// punctuation from the displayed card name to produce the file slug.
|
||||
// Reference: https://yugipedia.com/wiki/Yugipedia:Image_policy
|
||||
std::string out;
|
||||
out.reserve(name.size());
|
||||
for (unsigned char c : name) {
|
||||
if (c <= 0x20) continue; // whitespace, including non-breaking
|
||||
switch (c) {
|
||||
case '#': case ',': case '.': case ':': case '\'': case '"':
|
||||
case '?': case '!': case '&': case '@': case '%': case '=':
|
||||
case '[': case ']': case '<': case '>': case '/': case '\\':
|
||||
case '-': case '*': case ';': case '`':
|
||||
continue;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
out.push_back(static_cast<char>(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
|
||||
// Compare case-insensitively, ignoring whitespace, against a table of
|
||||
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
|
||||
// plus a few extras occasionally seen in imported collections. The codes
|
||||
// are the ones Yugipedia uses in image filenames.
|
||||
std::string lc;
|
||||
lc.reserve(rarityName.size());
|
||||
for (unsigned char c : rarityName) {
|
||||
if (std::isspace(c)) continue;
|
||||
lc.push_back(static_cast<char>(std::tolower(c)));
|
||||
}
|
||||
static const std::array<std::pair<std::string_view, std::string_view>, 32> kTable = {{
|
||||
{"common", "C"},
|
||||
{"shortprint", "SP"},
|
||||
{"supershortprint", "SSP"},
|
||||
{"normalrare", "NR"},
|
||||
{"rare", "R"},
|
||||
{"superrare", "SR"},
|
||||
{"ultrarare", "UR"},
|
||||
{"ultimaterare", "UtR"},
|
||||
{"secretrare", "ScR"},
|
||||
{"prismaticsecretrare", "PScR"},
|
||||
{"extrasecretrare", "EScR"},
|
||||
{"ultrasecretrare", "UScR"},
|
||||
{"platinumsecretrare", "PtScR"},
|
||||
{"goldsecretrare", "GScR"},
|
||||
{"ghostrare", "GR"},
|
||||
{"goldrare", "GUR"},
|
||||
{"premiumgoldrare", "PGR"},
|
||||
{"goldenrare", "GUR"},
|
||||
{"starfoilrare", "SFR"},
|
||||
{"shatterfoilrare", "SHR"},
|
||||
{"mosaicrare", "MSR"},
|
||||
{"parallelrare", "PR"},
|
||||
{"superparallelrare", "SPR"},
|
||||
{"ultraparallelrare", "UPR"},
|
||||
{"holographicrare", "HGR"},
|
||||
{"starlightrare", "StR"},
|
||||
{"collectorsrare", "ColR"},
|
||||
{"prismaticcollectorsrare", "PColR"},
|
||||
{"quartercenturysecretrare", "QCScR"},
|
||||
{"prismaticultimaterare", "PUtR"},
|
||||
{"prismaticredsecretrare", "PRScR"},
|
||||
{"silverletter", "SLR"},
|
||||
}};
|
||||
for (const auto& [k, v] : kTable) {
|
||||
if (lc == k) return std::string(v);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string YuGiOhCardPreviewSource::extractSetCode(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
const auto dash = s.find('-');
|
||||
if (dash == std::string::npos) return s;
|
||||
return s.substr(0, dash);
|
||||
}
|
||||
|
||||
std::vector<std::string> YuGiOhCardPreviewSource::buildCandidateFilenames(
|
||||
std::string_view name,
|
||||
std::string_view setCode,
|
||||
std::string_view rarityCode,
|
||||
bool firstEdition) {
|
||||
std::vector<std::string> out;
|
||||
const std::string slug = normalizeName(name);
|
||||
if (slug.empty() || setCode.empty()) return out;
|
||||
|
||||
// English-only region candidates, in rough usage order: EN is the
|
||||
// current default, NA was used on most LOB-era prints, EU/AU show up
|
||||
// sporadically. Always English regardless of the card's stored Language.
|
||||
static constexpr std::array<std::string_view, 4> kRegions =
|
||||
{"EN", "NA", "EU", "AU"};
|
||||
|
||||
// Edition candidates: prefer the printed edition the user has, then
|
||||
// try the opposite, then fall back to LE for promo-type prints.
|
||||
std::array<std::string_view, 3> editions = {"", "", "LE"};
|
||||
if (firstEdition) {
|
||||
editions[0] = "1E";
|
||||
editions[1] = "UE";
|
||||
} else {
|
||||
editions[0] = "UE";
|
||||
editions[1] = "1E";
|
||||
}
|
||||
|
||||
// Two extension variants: Yugipedia has a mix of .png (modern) and .jpg
|
||||
// (older uploads) for the same era. Both are common for LOB-era cards.
|
||||
static constexpr std::array<std::string_view, 2> kExts = {"png", "jpg"};
|
||||
|
||||
auto pushCombos = [&](std::string_view rarity) {
|
||||
for (auto edition : editions) {
|
||||
for (auto region : kRegions) {
|
||||
for (auto ext : kExts) {
|
||||
std::string fn;
|
||||
fn.reserve(slug.size() + setCode.size() + 16);
|
||||
fn += slug;
|
||||
fn += '-'; fn.append(setCode);
|
||||
fn += '-'; fn.append(region);
|
||||
if (!rarity.empty()) {
|
||||
fn += '-'; fn.append(rarity);
|
||||
}
|
||||
fn += '-'; fn.append(edition);
|
||||
fn += '.'; fn.append(ext);
|
||||
out.push_back(std::move(fn));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Primary attempts include the rarity slot. If we don't know the rarity
|
||||
// we skip straight to the rarity-less fallback (some sets are uniform
|
||||
// rarity and the upload omits the slot).
|
||||
if (!rarityCode.empty()) {
|
||||
pushCombos(rarityCode);
|
||||
}
|
||||
pushCombos("");
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
|
||||
const std::vector<std::string>& filenames) {
|
||||
// MediaWiki batch query: `titles=File:A|File:B|File:C` (URL-encoded).
|
||||
// One HTTP call returns imageinfo for every page whose file exists; the
|
||||
// missing ones come back tagged with `"missing": ""`.
|
||||
std::string joined;
|
||||
for (size_t i = 0; i < filenames.size(); ++i) {
|
||||
if (i > 0) joined += "|";
|
||||
joined += "File:";
|
||||
joined += filenames[i];
|
||||
}
|
||||
std::string url =
|
||||
"https://yugipedia.com/api.php?action=query&format=json"
|
||||
"&prop=imageinfo&iiprop=url&titles=";
|
||||
url += urlEncode(joined);
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaResponse(
|
||||
const std::string& body,
|
||||
const std::vector<std::string>& filenameOrder) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("query") || !j.at("query").is_object()) {
|
||||
return R::err({K::Transient, "Yugipedia response missing 'query' object."});
|
||||
}
|
||||
const auto& pages = j.at("query").value("pages", nlohmann::json::object());
|
||||
if (!pages.is_object()) {
|
||||
return R::err({K::Transient, "Yugipedia response missing 'query.pages'."});
|
||||
}
|
||||
|
||||
// Build a name->URL map. MediaWiki returns the title with namespace
|
||||
// ("File:...") and may have replaced spaces with underscores; our
|
||||
// candidate filenames never contain spaces, so a direct compare on
|
||||
// the bit after "File:" is sufficient.
|
||||
std::unordered_map<std::string, std::string> resolved;
|
||||
resolved.reserve(filenameOrder.size());
|
||||
for (auto it = pages.begin(); it != pages.end(); ++it) {
|
||||
const auto& page = it.value();
|
||||
if (!page.contains("imageinfo")) continue;
|
||||
const auto& info = page.at("imageinfo");
|
||||
if (!info.is_array() || info.empty()) continue;
|
||||
const auto& info0 = info.at(0);
|
||||
if (!info0.contains("url") || !info0.at("url").is_string()) continue;
|
||||
|
||||
std::string title = page.value("title", "");
|
||||
constexpr std::string_view kPrefix = "File:";
|
||||
if (title.rfind(kPrefix, 0) == 0) title.erase(0, kPrefix.size());
|
||||
resolved[title] = info0.at("url").get<std::string>();
|
||||
}
|
||||
|
||||
// Walk our ordered candidate list and return the first hit. This is
|
||||
// how priority works: 1E English first, then UE, then jpg, etc.
|
||||
for (const auto& fn : filenameOrder) {
|
||||
auto it = resolved.find(fn);
|
||||
if (it != resolved.end() && !it->second.empty()) {
|
||||
return R::ok(it->second);
|
||||
}
|
||||
}
|
||||
// Every candidate was tagged "missing" => Yugipedia confirmed there
|
||||
// is no English scan for this printing. Treat as NotFound; the
|
||||
// YGOPRODeck fallback may still surface a generic art.
|
||||
return R::err({K::NotFound, "No matching Yugipedia scan found."});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("Yugipedia JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YGOPRODeck (auto-detect path + last-resort fallback)
|
||||
// ============================================================================
|
||||
|
||||
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName) {
|
||||
std::string url =
|
||||
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
|
||||
if (!setName.empty()) {
|
||||
url += "&cardset=";
|
||||
url += urlEncode(setName);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseFallbackImageUrl(
|
||||
const std::string& body, std::string_view name) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return R::err({K::Transient, "YGOPRODeck response missing 'data' array."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return R::err({K::NotFound, "YGOPRODeck returned no matching cards."});
|
||||
}
|
||||
const std::string wantedNameLower = toLower(trim(std::string(name)));
|
||||
|
||||
// Prefer the exact-name match: the fuzzy `fname=` search can mix in
|
||||
// sibling cards (Dark Magician + Dark Magician Girl), and we don't
|
||||
// want to land on a sibling's standard art.
|
||||
for (const auto& card : data) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (!wantedNameLower.empty() && toLower(cardName) == wantedNameLower) {
|
||||
const std::string image = imageFromCard(card);
|
||||
if (!image.empty()) return R::ok(image);
|
||||
}
|
||||
}
|
||||
// Failing that, take whatever YGOPRODeck ranked first.
|
||||
const std::string image = imageFromCard(data.at(0));
|
||||
if (!image.empty()) {
|
||||
return R::ok(image);
|
||||
}
|
||||
return R::err({K::NotFound, "Card has no image variants."});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("YGOPRODeck JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view preferredSetName,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
|
||||
return R::err("YGOPRODeck returned no matching cards.");
|
||||
}
|
||||
const std::string wantedSet = trim(std::string(preferredSetName));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
auto pushPrint = [&collected](const nlohmann::json& print) {
|
||||
AutoDetectedPrint out;
|
||||
out.setNo = trim(print.value("set_code", ""));
|
||||
out.rarity = trim(print.value("set_rarity", ""));
|
||||
if (out.setNo.empty() && out.rarity.empty()) return;
|
||||
collected.push_back(std::move(out));
|
||||
};
|
||||
|
||||
for (const auto& card : j.at("data")) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
|
||||
for (const auto& print : card.at("card_sets")) {
|
||||
const std::string setName = trim(print.value("set_name", ""));
|
||||
if (!wantedSet.empty() && setName != wantedSet) continue;
|
||||
pushPrint(print);
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror parseFirstPrint fallback: if nothing matched `wantedSet`, take
|
||||
// every print from `data[0]` without filtering by set_name.
|
||||
//
|
||||
// When the caller supplied an exact card name (edit-dialog variant
|
||||
// listing), combining unrelated `card_sets[]` rows after a non-empty
|
||||
// display-set filter missed would falsely imply multiple printings
|
||||
// "in one set" (different real-world products share the same card).
|
||||
if (collected.empty()) {
|
||||
if (!wantedNameLower.empty() && !wantedSet.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
const auto& firstCard = j.at("data").at(0);
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(firstCard.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
}
|
||||
if (firstCard.contains("card_sets") && firstCard.at("card_sets").is_array()) {
|
||||
for (const auto& print : firstCard.at("card_sets")) {
|
||||
pushPrint(print);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
const std::string key = p.setNo + '\0' + p.rarity;
|
||||
if (seen.insert(key).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::parseFirstPrint(
|
||||
const std::string& body, std::string_view preferredSetName) {
|
||||
auto list = parsePrintVariants(body, preferredSetName, "");
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public ICardPreviewSource API
|
||||
// ============================================================================
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
YuGiOhCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view /*setId*/,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
const ParsedSetNo p = parseSetNoTuple(setNo);
|
||||
const std::string setCode = extractSetCode(p.setNo);
|
||||
const std::string rarityCode = rarityCodeFor(p.rarity);
|
||||
const bool firstEdition = (p.edition == "1E");
|
||||
|
||||
// The overall classification needs the worst outcome across the two
|
||||
// upstreams: NotFound only when *both* answered cleanly with no match,
|
||||
// Transient as soon as either one couldn't speak. We track Yugipedia's
|
||||
// outcome here and combine it with YGOPRODeck's below.
|
||||
bool yugipediaSawTransient = false;
|
||||
PreviewLookupError yugipediaErr{K::NotFound, "Yugipedia not consulted."};
|
||||
|
||||
// Step 1: Yugipedia per-printing scan. Build a batch of plausible English
|
||||
// filenames and ask MediaWiki for them all in one call. This is the only
|
||||
// source we know of that distinguishes art between same-passcode reprints
|
||||
// (LOB Blue-Eyes vs SDK Blue-Eyes, etc.).
|
||||
//
|
||||
// No usable set code (or empty candidate list) is treated as an
|
||||
// "inapplicable" Yugipedia step rather than a failure - we don't want a
|
||||
// legitimate metadata gap to taint the final classification as transient.
|
||||
if (!setCode.empty()) {
|
||||
const auto candidates = buildCandidateFilenames(
|
||||
name, setCode, rarityCode, firstEdition);
|
||||
if (!candidates.empty()) {
|
||||
const std::string url = buildYugipediaQueryUrl(candidates);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) {
|
||||
yugipediaSawTransient = true;
|
||||
yugipediaErr = {K::Transient, resp.error()};
|
||||
} else {
|
||||
auto parsed = parseYugipediaResponse(resp.value(), candidates);
|
||||
if (parsed) return parsed;
|
||||
yugipediaErr = std::move(parsed).error();
|
||||
if (yugipediaErr.kind == K::Transient) yugipediaSawTransient = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: YGOPRODeck standard-art fallback. Only used when Yugipedia has
|
||||
// no scan we can match (newly-added cards, OCG-only cards without an
|
||||
// English release, transient Yugipedia errors). Always unfiltered, so
|
||||
// card_images[0] is the original artwork rather than an alt-art reprint.
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) {
|
||||
// YGOPRODeck failed at the network layer => the overall lookup is
|
||||
// transient regardless of what Yugipedia did. Surface YGOPRODeck's
|
||||
// error string because it's the most recent failure.
|
||||
return R::err({K::Transient, fallback.error()});
|
||||
}
|
||||
auto parsed = parseFallbackImageUrl(fallback.value(), name);
|
||||
if (parsed) return parsed;
|
||||
|
||||
// Both upstreams answered. If *either* one was transient, the overall
|
||||
// outcome is transient (we can't conclude the record has no image).
|
||||
PreviewLookupError fallbackErr = std::move(parsed).error();
|
||||
if (yugipediaSawTransient || fallbackErr.kind == K::Transient) {
|
||||
return R::err({K::Transient,
|
||||
yugipediaSawTransient ? yugipediaErr.message : fallbackErr.message});
|
||||
}
|
||||
// Otherwise both confirmed "no image" => safe to remember.
|
||||
return R::err({K::NotFound, fallbackErr.message});
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto list = detectPrintVariants(name, setId);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setId);
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setId, name);
|
||||
}
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setId, name);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
YuGiOhGameModule::YuGiOhGameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"YGOPRODeck response is not an array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
s.id = entry.value("set_code", "");
|
||||
s.name = entry.value("set_name", "");
|
||||
std::string release = entry.value("tcg_date", "");
|
||||
for (char& ch : release) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
s.releaseDate = std::move(release);
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("YGOPRODeck set parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -3,19 +3,40 @@
|
||||
#include <cpr/cpr.h>
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout) : timeout_(timeout) {}
|
||||
CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
|
||||
: timeout_(timeout),
|
||||
session_(std::make_unique<cpr::Session>()) {
|
||||
// Configure session-wide options once; every Get() then only updates
|
||||
// the URL. libcurl's connection cache lives inside the easy handle, so
|
||||
// reusing one Session across calls is what gets us TLS keep-alive.
|
||||
session_->SetTimeout(cpr::Timeout{timeout_});
|
||||
// `Accept: application/json` breaks some CDNs that refuse non-JSON bodies
|
||||
// (preview pipeline also GETs raw JPG/PNG). Wildcard keeps JSON APIs happy.
|
||||
session_->SetHeader(cpr::Header{
|
||||
{"User-Agent", "card-collection-manager-3/0.1"},
|
||||
{"Accept", "*/*"},
|
||||
});
|
||||
session_->SetRedirect(cpr::Redirect{/*max_redirects=*/10L,
|
||||
/*follow=*/true,
|
||||
/*cont_send_cred=*/false,
|
||||
cpr::PostRedirectFlags::POST_ALL});
|
||||
}
|
||||
|
||||
CprHttpClient::~CprHttpClient() = default;
|
||||
|
||||
Result<std::string> CprHttpClient::get(std::string_view url) {
|
||||
cpr::Response r = cpr::Get(
|
||||
cpr::Url{std::string(url)},
|
||||
cpr::Timeout{timeout_},
|
||||
// Identify ourselves; some APIs rate-limit unknown agents harshly.
|
||||
cpr::Header{{"User-Agent", "card-collection-manager-3/0.1"},
|
||||
{"Accept", "application/json"}}
|
||||
);
|
||||
// libcurl easy handles (and therefore cpr::Session) are not thread-safe.
|
||||
// We serialize callers here; the preview path is single-flight already
|
||||
// (one fetch per BaseSelectedCardPanel selection change), so contention
|
||||
// is negligible.
|
||||
std::lock_guard<std::mutex> lock(sessionMutex_);
|
||||
|
||||
session_->SetUrl(cpr::Url{std::string(url)});
|
||||
cpr::Response r = session_->Get();
|
||||
|
||||
if (r.error) {
|
||||
return Result<std::string>::err("HTTP error: " + r.error.message);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#include "ccm/infra/LocalPreviewByteCache.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// FNV-1a 64-bit hash, hex-encoded. We don't need cryptographic strength
|
||||
// here: the `.idx` sidecar file holds the original key and load() rejects
|
||||
// any mismatch, so a hash collision degrades to a cache miss instead of a
|
||||
// wrong-image return. FNV-1a was picked to keep this dependency-free
|
||||
// (no openssl, no extra link).
|
||||
std::string fnv1a64Hex(std::string_view in) {
|
||||
constexpr std::uint64_t kOffsetBasis = 0xcbf29ce484222325ULL;
|
||||
constexpr std::uint64_t kPrime = 0x100000001b3ULL;
|
||||
std::uint64_t h = kOffsetBasis;
|
||||
for (unsigned char c : in) {
|
||||
h ^= c;
|
||||
h *= kPrime;
|
||||
}
|
||||
std::ostringstream oss;
|
||||
oss << std::hex << std::setw(16) << std::setfill('0') << h;
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
// Best-effort mtime; returns the epoch on any error so callers can still
|
||||
// sort consistently (oldest-first eviction stays well-defined).
|
||||
fs::file_time_type mtimeOrEpoch(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
auto t = fs::last_write_time(p, ec);
|
||||
if (ec) return fs::file_time_type{};
|
||||
return t;
|
||||
}
|
||||
|
||||
std::uintmax_t fileSizeOrZero(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
auto sz = fs::file_size(p, ec);
|
||||
return ec ? 0u : sz;
|
||||
}
|
||||
|
||||
void touchMtime(const fs::path& p) {
|
||||
std::error_code ec;
|
||||
fs::last_write_time(p, fs::file_time_type::clock::now(), ec);
|
||||
// Ignored: touch is a best-effort hint to the LRU policy.
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
LocalPreviewByteCache::LocalPreviewByteCache(IFileSystem& fs,
|
||||
fs::path cacheDir,
|
||||
std::size_t maxBytes)
|
||||
: fs_(fs), cacheDir_(std::move(cacheDir)), maxBytes_(maxBytes) {}
|
||||
|
||||
std::string LocalPreviewByteCache::hashKey(std::string_view key) {
|
||||
return fnv1a64Hex(key);
|
||||
}
|
||||
|
||||
fs::path LocalPreviewByteCache::payloadPath(const std::string& hash) const {
|
||||
return cacheDir_ / (hash + ".bin");
|
||||
}
|
||||
|
||||
fs::path LocalPreviewByteCache::negativePath(const std::string& hash) const {
|
||||
return cacheDir_ / (hash + ".neg");
|
||||
}
|
||||
|
||||
fs::path LocalPreviewByteCache::indexPath(const std::string& hash) const {
|
||||
return cacheDir_ / (hash + ".idx");
|
||||
}
|
||||
|
||||
IPreviewByteCache::LoadResult LocalPreviewByteCache::load(std::string_view key) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
const std::string hash = hashKey(key);
|
||||
const auto bin = payloadPath(hash);
|
||||
const auto neg = negativePath(hash);
|
||||
const auto idx = indexPath(hash);
|
||||
|
||||
const bool hasBin = fs_.exists(bin);
|
||||
const bool hasNeg = fs_.exists(neg);
|
||||
if (!hasBin && !hasNeg) return {HitKind::Miss, {}};
|
||||
|
||||
// Sidecar must exist and match exactly. Anything else - missing,
|
||||
// mismatched, empty - is treated as a miss so the next store() /
|
||||
// storeNegative() will overwrite cleanly. This is what guarantees that
|
||||
// a hash collision can never serve another card's bytes or stale
|
||||
// "no image" verdict.
|
||||
if (!fs_.exists(idx)) return {HitKind::Miss, {}};
|
||||
auto idxRead = fs_.readText(idx);
|
||||
if (!idxRead) return {HitKind::Miss, {}};
|
||||
if (idxRead.value() != key) return {HitKind::Miss, {}};
|
||||
|
||||
if (hasBin) {
|
||||
auto payload = fs_.readText(bin);
|
||||
if (!payload) return {HitKind::Miss, {}};
|
||||
// Touch mtime so this hit moves to the front of the LRU.
|
||||
touchMtime(bin);
|
||||
return {HitKind::Hit, std::move(payload).value()};
|
||||
}
|
||||
// Negative-only entry. Touch its mtime as well so frequently-checked
|
||||
// negatives don't get aged out by an arbitrary directory sweep.
|
||||
touchMtime(neg);
|
||||
return {HitKind::NegativeHit, {}};
|
||||
}
|
||||
|
||||
void LocalPreviewByteCache::store(std::string_view key, const std::string& payload) {
|
||||
if (payload.empty()) return;
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
auto ensure = fs_.ensureDirectory(cacheDir_);
|
||||
if (!ensure) return;
|
||||
|
||||
const std::string hash = hashKey(key);
|
||||
const auto bin = payloadPath(hash);
|
||||
const auto neg = negativePath(hash);
|
||||
const auto idx = indexPath(hash);
|
||||
|
||||
// If a negative entry exists for this exact key, drop it before writing
|
||||
// the positive payload so the two are never co-resident on disk.
|
||||
if (fs_.exists(neg)) (void)fs_.remove(neg);
|
||||
|
||||
// Eviction runs against the *new* payload size, not the post-write
|
||||
// total, so we make room before writing. If the same key is being
|
||||
// overwritten the existing payload's bytes are released first.
|
||||
evictIfNeededLocked(payload.size());
|
||||
|
||||
auto wrote = fs_.writeText(bin, payload);
|
||||
if (!wrote) return;
|
||||
auto wroteIdx = fs_.writeText(idx, std::string(key));
|
||||
if (!wroteIdx) {
|
||||
// Sidecar failure leaves us with bytes we can't safely serve later.
|
||||
// Roll back the payload write so a future load() doesn't see it.
|
||||
(void)fs_.remove(bin);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void LocalPreviewByteCache::storeNegative(std::string_view key) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
auto ensure = fs_.ensureDirectory(cacheDir_);
|
||||
if (!ensure) return;
|
||||
|
||||
const std::string hash = hashKey(key);
|
||||
const auto bin = payloadPath(hash);
|
||||
const auto neg = negativePath(hash);
|
||||
const auto idx = indexPath(hash);
|
||||
|
||||
// Replace any existing positive entry: storeNegative is the upstream
|
||||
// saying "the previous bytes are no longer the correct answer for this
|
||||
// record". Free the bytes from the size cap immediately.
|
||||
if (fs_.exists(bin)) (void)fs_.remove(bin);
|
||||
|
||||
// Order matters: write the marker first, then the sidecar. If the
|
||||
// sidecar write fails we delete the marker to avoid a half-written
|
||||
// entry that load() would treat as a miss anyway but that contributes
|
||||
// a stray file to the directory listing.
|
||||
auto wroteNeg = fs_.writeText(neg, std::string{});
|
||||
if (!wroteNeg) return;
|
||||
auto wroteIdx = fs_.writeText(idx, std::string(key));
|
||||
if (!wroteIdx) {
|
||||
(void)fs_.remove(neg);
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t LocalPreviewByteCache::currentSizeBytes() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto entries = fs_.listDirectory(cacheDir_);
|
||||
if (!entries) return 0;
|
||||
std::size_t total = 0;
|
||||
for (const auto& p : entries.value()) {
|
||||
if (p.extension() == ".bin") total += static_cast<std::size_t>(fileSizeOrZero(p));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void LocalPreviewByteCache::evictIfNeededLocked(std::size_t incomingBytes) {
|
||||
auto entries = fs_.listDirectory(cacheDir_);
|
||||
if (!entries) return;
|
||||
|
||||
struct Entry {
|
||||
fs::path bin;
|
||||
fs::path idx;
|
||||
std::uintmax_t size;
|
||||
fs::file_time_type mtime;
|
||||
};
|
||||
std::vector<Entry> bins;
|
||||
bins.reserve(entries.value().size());
|
||||
std::size_t total = 0;
|
||||
for (const auto& p : entries.value()) {
|
||||
if (p.extension() != ".bin") continue;
|
||||
Entry e;
|
||||
e.bin = p;
|
||||
e.idx = p;
|
||||
e.idx.replace_extension(".idx");
|
||||
e.size = fileSizeOrZero(p);
|
||||
e.mtime = mtimeOrEpoch(p);
|
||||
total += static_cast<std::size_t>(e.size);
|
||||
bins.push_back(std::move(e));
|
||||
}
|
||||
|
||||
if (total + incomingBytes <= maxBytes_) return;
|
||||
|
||||
std::sort(bins.begin(), bins.end(),
|
||||
[](const Entry& a, const Entry& b) { return a.mtime < b.mtime; });
|
||||
|
||||
for (const auto& e : bins) {
|
||||
if (total + incomingBytes <= maxBytes_) break;
|
||||
// remove() is best-effort; if it fails we still drop our accounting
|
||||
// for the entry so we don't loop forever on a stuck file.
|
||||
(void)fs_.remove(e.bin);
|
||||
(void)fs_.remove(e.idx);
|
||||
total -= std::min<std::size_t>(static_cast<std::size_t>(e.size), total);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -63,4 +63,20 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
|
||||
if (filter.empty()) return true;
|
||||
|
||||
const std::string needle = asciiLower(filter);
|
||||
|
||||
if (containsLower(card.name, needle)) return true;
|
||||
if (containsLower(card.set.name, needle)) return true;
|
||||
if (containsLower(card.setNo, needle)) return true;
|
||||
if (containsLower(card.rarity, needle)) return true;
|
||||
if (containsLower(to_string(card.language), needle)) return true;
|
||||
if (containsLower(to_string(card.condition), needle)) return true;
|
||||
if (containsLower(std::to_string(card.amount), needle)) return true;
|
||||
if (containsLower(card.note, needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http) : http_(http) {}
|
||||
namespace {
|
||||
|
||||
// Compose a stable cache key from the four lookup coordinates. Using NUL as
|
||||
// a separator keeps the key unambiguous even if a card's name happens to
|
||||
// contain `|` or other punctuation.
|
||||
std::string makePreviewKey(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
std::string k;
|
||||
k.reserve(2 + name.size() + setId.size() + setNo.size() + 3);
|
||||
k.push_back('p');
|
||||
k.push_back(static_cast<char>(static_cast<int>(game)));
|
||||
k.push_back('\0');
|
||||
k.append(name);
|
||||
k.push_back('\0');
|
||||
k.append(setId);
|
||||
k.push_back('\0');
|
||||
k.append(setNo);
|
||||
return k;
|
||||
}
|
||||
|
||||
std::string makeUrlKey(std::string_view url) {
|
||||
std::string k;
|
||||
k.reserve(url.size() + 1);
|
||||
k.push_back('u');
|
||||
k.append(url);
|
||||
return k;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache)
|
||||
: http_(http), persistentCache_(persistentCache) {}
|
||||
|
||||
void CardPreviewService::registerModule(IGameModule& module) {
|
||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
||||
@@ -10,6 +48,80 @@ void CardPreviewService::registerModule(IGameModule& module) {
|
||||
}
|
||||
}
|
||||
|
||||
CardPreviewService::CacheLookupKind CardPreviewService::cacheLookup(
|
||||
const std::string& key, std::string& outPayload) {
|
||||
std::lock_guard<std::mutex> lock(cacheMutex_);
|
||||
auto it = cacheIndex_.find(key);
|
||||
if (it == cacheIndex_.end()) {
|
||||
outPayload.clear();
|
||||
return CacheLookupKind::Miss;
|
||||
}
|
||||
// Move-to-front to mark as most-recently-used.
|
||||
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
|
||||
if (it->second->negative) {
|
||||
outPayload.clear();
|
||||
return CacheLookupKind::NegativeHit;
|
||||
}
|
||||
outPayload = it->second->payload;
|
||||
return CacheLookupKind::Hit;
|
||||
}
|
||||
|
||||
void CardPreviewService::cacheStore(const std::string& key, std::string payload) {
|
||||
if (payload.empty()) return;
|
||||
std::lock_guard<std::mutex> lock(cacheMutex_);
|
||||
auto it = cacheIndex_.find(key);
|
||||
if (it != cacheIndex_.end()) {
|
||||
// Overwrite existing entry (positive or negative) and bump it to
|
||||
// the front. Replacing a negative entry is the "we got a real
|
||||
// image after a previous NotFound" path - rare but valid.
|
||||
it->second->payload = std::move(payload);
|
||||
it->second->negative = false;
|
||||
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
|
||||
return;
|
||||
}
|
||||
cacheList_.push_front({key, std::move(payload), /*negative=*/false});
|
||||
cacheIndex_.emplace(key, cacheList_.begin());
|
||||
while (cacheList_.size() > kCacheCapacity) {
|
||||
cacheIndex_.erase(cacheList_.back().key);
|
||||
cacheList_.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
void CardPreviewService::cacheStoreNegative(const std::string& key) {
|
||||
std::lock_guard<std::mutex> lock(cacheMutex_);
|
||||
auto it = cacheIndex_.find(key);
|
||||
if (it != cacheIndex_.end()) {
|
||||
it->second->payload.clear();
|
||||
it->second->negative = true;
|
||||
cacheList_.splice(cacheList_.begin(), cacheList_, it->second);
|
||||
return;
|
||||
}
|
||||
cacheList_.push_front({key, std::string{}, /*negative=*/true});
|
||||
cacheIndex_.emplace(key, cacheList_.begin());
|
||||
while (cacheList_.size() > kCacheCapacity) {
|
||||
cacheIndex_.erase(cacheList_.back().key);
|
||||
cacheList_.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKey,
|
||||
std::string_view url) {
|
||||
auto bytes = http_.get(url);
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
std::string payload = std::move(bytes).value();
|
||||
if (payload.empty()) {
|
||||
return Result<std::string>::err("Empty response body from " + std::string(url));
|
||||
}
|
||||
cacheStore(cacheKey, payload);
|
||||
// Best-effort persist to disk so the next app launch starts warm.
|
||||
// The persistent tier is fire-and-forget: any I/O error is swallowed
|
||||
// by the adapter, the in-memory tier still holds the bytes.
|
||||
if (persistentCache_ != nullptr) {
|
||||
persistentCache_->store(cacheKey, payload);
|
||||
}
|
||||
return Result<std::string>::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
@@ -18,17 +130,114 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<std::string>::err("No preview source registered for this game.");
|
||||
}
|
||||
|
||||
// Cache check before any HTTP call. The (game, name, setId, setNo) tuple
|
||||
// uniquely identifies a printing for our purposes - the resolved image
|
||||
// URL is always a deterministic function of those four inputs, and any
|
||||
// edit to a lookup-relevant field changes the key automatically.
|
||||
const std::string key = makePreviewKey(game, name, setId, setNo);
|
||||
std::string cached;
|
||||
switch (cacheLookup(key, cached)) {
|
||||
case CacheLookupKind::Hit:
|
||||
return Result<std::string>::ok(std::move(cached));
|
||||
case CacheLookupKind::NegativeHit:
|
||||
return Result<std::string>::err("No preview available for this card.");
|
||||
case CacheLookupKind::Miss:
|
||||
break;
|
||||
}
|
||||
// Disk-backed second tier: previews persisted by an earlier app run
|
||||
// get promoted into the in-memory LRU on first access this session, so
|
||||
// subsequent re-selections stay fast without re-touching the network.
|
||||
// Negative entries on disk are likewise promoted - the user already
|
||||
// knows from a previous session that this record has no upstream image.
|
||||
if (persistentCache_ != nullptr) {
|
||||
const auto disk = persistentCache_->load(key);
|
||||
switch (disk.kind) {
|
||||
case IPreviewByteCache::HitKind::Hit:
|
||||
cacheStore(key, disk.payload);
|
||||
return Result<std::string>::ok(disk.payload);
|
||||
case IPreviewByteCache::HitKind::NegativeHit:
|
||||
cacheStoreNegative(key);
|
||||
return Result<std::string>::err("No preview available for this card.");
|
||||
case IPreviewByteCache::HitKind::Miss:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
auto url = it->second->fetchImageUrl(name, setId, setNo);
|
||||
if (!url) return Result<std::string>::err(url.error());
|
||||
auto bytes = http_.get(url.value());
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
return Result<std::string>::ok(std::move(bytes).value());
|
||||
if (!url) {
|
||||
// The two error kinds split here:
|
||||
// - NotFound: upstream answered cleanly that this record has no
|
||||
// image. Persist the verdict so we don't keep retrying.
|
||||
// - Transient: network/HTTP/parse failure. Surface the error
|
||||
// unchanged and DO NOT cache anything; the next selection
|
||||
// retries from scratch.
|
||||
const auto err = std::move(url).error();
|
||||
if (err.kind == PreviewLookupError::Kind::NotFound) {
|
||||
cacheStoreNegative(key);
|
||||
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
return fetchAndCache(key, url.value());
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> CardPreviewService::detectFirstPrint(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto it = sources_.find(game);
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
|
||||
}
|
||||
if (!it->second->supportsAutoDetectPrint()) {
|
||||
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
|
||||
}
|
||||
return it->second->detectFirstPrint(name, setId);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
|
||||
Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto it = sources_.find(game);
|
||||
if (it == sources_.end() || it->second == nullptr) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"No preview source registered for this game.");
|
||||
}
|
||||
if (!it->second->supportsAutoDetectPrint()) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Auto-detect not enabled for this game.");
|
||||
}
|
||||
return it->second->detectPrintVariants(name, setId);
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
|
||||
auto bytes = http_.get(url);
|
||||
if (!bytes) return Result<std::string>::err(bytes.error());
|
||||
return Result<std::string>::ok(std::move(bytes).value());
|
||||
// The by-URL path is used for fixed per-game card-back fallback images.
|
||||
// A failure there is always transient (the URL itself is constant), so
|
||||
// there is no negative-cache analogue to worry about; we just look up
|
||||
// and, if needed, fetch+store.
|
||||
const std::string key = makeUrlKey(url);
|
||||
std::string cached;
|
||||
switch (cacheLookup(key, cached)) {
|
||||
case CacheLookupKind::Hit:
|
||||
return Result<std::string>::ok(std::move(cached));
|
||||
case CacheLookupKind::NegativeHit:
|
||||
// Defensive: nothing in this code path ever stores a negative
|
||||
// entry under a URL key, but if one ever ends up here (cache
|
||||
// file tampering, future code paths) treat it as a miss so the
|
||||
// fallback fetch can still run.
|
||||
break;
|
||||
case CacheLookupKind::Miss:
|
||||
break;
|
||||
}
|
||||
if (persistentCache_ != nullptr) {
|
||||
const auto disk = persistentCache_->load(key);
|
||||
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
|
||||
cacheStore(key, disk.payload);
|
||||
return Result<std::string>::ok(disk.payload);
|
||||
}
|
||||
}
|
||||
return fetchAndCache(key, url);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -170,4 +170,64 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
}
|
||||
}
|
||||
|
||||
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case YuGiOhSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case YuGiOhSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const YuGiOhCard& a, const YuGiOhCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
Reference in New Issue
Block a user