Files
Card-Collection-Manager-3/core/src/services/CardPreviewService.cpp
T
2026-07-30 14:51:53 +02:00

321 lines
13 KiB
C++

#include "ccm/services/CardPreviewService.hpp"
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
namespace ccm {
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;
}
constexpr std::string_view kAssetScheme = "asset:";
} // namespace
CardPreviewService::CardPreviewService(IHttpClient& http,
IPreviewByteCache* persistentCache,
IFileSystem* fs,
std::filesystem::path assetRoot)
: http_(http),
persistentCache_(persistentCache),
fs_(fs),
assetRoot_(std::move(assetRoot)) {}
void CardPreviewService::registerModule(IGameModule& module) {
if (auto* src = module.cardPreviewSource(); src != nullptr) {
sources_[module.id()] = src;
}
}
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, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
const std::string& cacheKey,
std::string_view assetUrl) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
if (fs_ == nullptr || assetRoot_.empty()) {
return R::err({K::Transient, "Asset preview path is not configured."});
}
if (!assetUrl.starts_with(kAssetScheme)) {
return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."});
}
std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size())));
const auto fullPath = assetRoot_ / rel;
auto bytes = fs_->readText(fullPath);
if (!bytes) {
return R::err({K::NotFound,
"Bundled preview asset not found: " + fullPath.generic_string()});
}
std::string payload = std::move(bytes).value();
if (payload.empty()) {
return R::err({K::NotFound,
"Bundled preview asset is empty: " + fullPath.generic_string()});
}
cacheStore(cacheKey, payload);
if (persistentCache_ != nullptr) {
persistentCache_->store(cacheKey, payload);
}
return R::ok(std::move(payload));
}
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(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) {
// 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);
}
if (url.value().starts_with(kAssetScheme)) {
auto asset = fetchAssetAndCache(key, url.value());
if (!asset) {
const auto err = std::move(asset).error();
if (err.kind == PreviewLookupError::Kind::NotFound) {
cacheStoreNegative(key);
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
}
return Result<std::string>::err(err.message);
}
return Result<std::string>::ok(std::move(asset).value());
}
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<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
std::string_view setNo) {
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->detectBySetNo(setNo);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
Game game,
std::string_view setNo) {
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->detectVariantsBySetNo(setNo);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
// 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;
const auto mem = cacheLookup(key, cached);
if (mem == CacheLookupKind::Hit) {
return Result<std::string>::ok(std::move(cached));
}
// Miss, or a spurious negative under a URL key (never written by normal
// code) — both continue to disk / network.
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);
}
}
if (url.starts_with(kAssetScheme)) {
auto asset = fetchAssetAndCache(key, url);
if (!asset) return Result<std::string>::err(asset.error().message);
return Result<std::string>::ok(std::move(asset).value());
}
return fetchAndCache(key, url);
}
} // namespace ccm