mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
321 lines
13 KiB
C++
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
|