a-la-karte/src/heroicimporter.cpp
Marco Allegretti 9c3c0e1dfd Import: Improve Heroic cover handling
Prefer cached cover images when available and import sideload apps.

Also accept Legendary-only setups and normalize cover metadata from the
Heroic configuration.
2026-01-24 13:51:53 +01:00

697 lines
23 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2024 A-La-Karte Contributors
#include "heroicimporter.h"
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
HeroicImporter::HeroicImporter(QObject *parent)
: PlatformImporter(parent)
{
}
QString HeroicImporter::platformName() const
{
return QStringLiteral("Heroic");
}
QString HeroicImporter::platformId() const
{
return QStringLiteral("heroic");
}
bool HeroicImporter::isAvailable() const
{
return directoryExists(findHeroicConfigPath()) || directoryExists(findLegendaryConfigPath());
}
QString HeroicImporter::findHeroicConfigPath() const
{
QStringList possiblePaths = {expandPath(QStringLiteral("~/.config/heroic")),
expandPath(QStringLiteral("~/.var/app/com.heroicgameslauncher.hgl/config/heroic"))};
for (const QString &path : possiblePaths) {
if (directoryExists(path)) {
return path;
}
}
return {};
}
QString HeroicImporter::findLegendaryConfigPath() const
{
QStringList possiblePaths = {expandPath(QStringLiteral("~/.config/legendary")),
expandPath(QStringLiteral("~/.var/app/com.heroicgameslauncher.hgl/config/legendary"))};
for (const QString &path : possiblePaths) {
if (directoryExists(path)) {
return path;
}
}
return {};
}
QString HeroicImporter::launchCommandForApp(const QString &appName) const
{
if (!findExecutable(QStringLiteral("heroic")).isEmpty()) {
return QStringLiteral("heroic --launch %1").arg(appName);
}
if (!findExecutable(QStringLiteral("flatpak")).isEmpty()) {
return QStringLiteral("flatpak run com.heroicgameslauncher.hgl --launch %1").arg(appName);
}
return QStringLiteral("heroic --launch %1").arg(appName);
}
QUrl HeroicImporter::extractCoverUrl(const QJsonObject &gameObj) const
{
static const QStringList stringKeys = {
QStringLiteral("coverUrl"),
QStringLiteral("cover_url"),
QStringLiteral("cover"),
QStringLiteral("image"),
QStringLiteral("imageUrl"),
QStringLiteral("image_url"),
QStringLiteral("art_cover"),
QStringLiteral("artCover"),
QStringLiteral("art_square"),
QStringLiteral("artSquare"),
QStringLiteral("thumbnail"),
};
for (const QString &key : stringKeys) {
const QString urlString = gameObj.value(key).toString();
if (!urlString.isEmpty()) {
const QUrl url(urlString);
if (url.isValid()) {
return url;
}
}
}
const QJsonValue keyImagesValue = gameObj.value(QStringLiteral("keyImages"));
if (keyImagesValue.isArray()) {
const QJsonArray keyImages = keyImagesValue.toArray();
for (const QJsonValue &imgValue : keyImages) {
const QJsonObject imgObj = imgValue.toObject();
const QString type = imgObj.value(QStringLiteral("type")).toString();
if (type == QStringLiteral("DieselGameBoxTall") || type == QStringLiteral("Thumbnail") || type == QStringLiteral("Poster")) {
const QString urlString = imgObj.value(QStringLiteral("url")).toString();
if (!urlString.isEmpty()) {
const QUrl url(urlString);
if (url.isValid()) {
return url;
}
}
}
}
}
const QJsonValue imagesValue = gameObj.value(QStringLiteral("images"));
if (imagesValue.isArray()) {
const QJsonArray images = imagesValue.toArray();
for (const QJsonValue &imgValue : images) {
const QJsonObject imgObj = imgValue.toObject();
const QString urlString = imgObj.value(QStringLiteral("url")).toString();
if (!urlString.isEmpty()) {
const QUrl url(urlString);
if (url.isValid()) {
return url;
}
}
}
}
return {};
}
QUrl HeroicImporter::preferCachedCoverUrl(const QUrl &coverUrl) const
{
if (!coverUrl.isValid()) {
return {};
}
if (coverUrl.isLocalFile() || coverUrl.scheme() == QLatin1String("file")) {
return coverUrl;
}
QString urlString = coverUrl.toString();
// Heroic uses an imagecache:// scheme which percent-encodes the real image URL.
if (coverUrl.scheme() == QLatin1String("imagecache")) {
const QString prefix = QStringLiteral("imagecache://");
if (urlString.startsWith(prefix)) {
urlString = QUrl::fromPercentEncoding(urlString.mid(prefix.size()).toUtf8());
}
}
const QUrl realUrl(urlString);
if (!realUrl.isValid()) {
return coverUrl;
}
if (realUrl.scheme() != QLatin1String("http") && realUrl.scheme() != QLatin1String("https")) {
return realUrl;
}
const QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
return realUrl;
}
const QString cacheDir = configPath + QStringLiteral("/images-cache");
if (!QDir(cacheDir).exists()) {
return realUrl;
}
// Upstream Heroic stores cached images as sha256(url) (no extension) inside images-cache.
const QByteArray digest = QCryptographicHash::hash(realUrl.toString().toUtf8(), QCryptographicHash::Sha256).toHex();
const QString cachedPath = cacheDir + QStringLiteral("/") + QString::fromLatin1(digest);
if (QFile::exists(cachedPath)) {
return QUrl::fromLocalFile(cachedPath);
}
return realUrl;
}
QUrl HeroicImporter::findCoverImage(const QString &appName, const QString &store) const
{
QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
configPath = findLegendaryConfigPath();
}
// Check images cache
QStringList imageDirs = {configPath + QStringLiteral("/images-cache"), configPath + QStringLiteral("/images")};
if (!store.isEmpty()) {
const QString storeLower = store.toLower();
imageDirs.append(configPath + QStringLiteral("/images-cache/") + storeLower);
imageDirs.append(configPath + QStringLiteral("/images/") + storeLower);
}
QStringList extensions = {QStringLiteral(".jpg"), QStringLiteral(".jpeg"), QStringLiteral(".png"), QStringLiteral(".webp")};
QStringList prefixes = {appName, appName + QStringLiteral("_cover"), appName + QStringLiteral("_library")};
for (const QString &imageDir : imageDirs) {
QDir dir(imageDir);
if (!dir.exists())
continue;
for (const QString &prefix : prefixes) {
for (const QString &ext : extensions) {
QString imagePath = imageDir + QStringLiteral("/") + prefix + ext;
if (QFile::exists(imagePath)) {
return QUrl::fromLocalFile(imagePath);
}
}
}
QStringList wildcardFilters;
wildcardFilters.reserve(extensions.count());
for (const QString &ext : extensions) {
wildcardFilters.append(QStringLiteral("*") + appName + QStringLiteral("*") + ext);
}
const QStringList wildcardMatches = dir.entryList(wildcardFilters, QDir::Files);
if (!wildcardMatches.isEmpty()) {
return QUrl::fromLocalFile(imageDir + QStringLiteral("/") + wildcardMatches.first());
}
// Also try looking in subdirectories
QStringList subDirs = dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
for (const QString &subDir : subDirs) {
for (const QString &prefix : prefixes) {
for (const QString &ext : extensions) {
QString imagePath = imageDir + QStringLiteral("/") + subDir + QStringLiteral("/") + prefix + ext;
if (QFile::exists(imagePath)) {
return QUrl::fromLocalFile(imagePath);
}
}
}
QDir sub(imageDir + QStringLiteral("/") + subDir);
if (sub.exists()) {
const QStringList subMatches = sub.entryList(wildcardFilters, QDir::Files);
if (!subMatches.isEmpty()) {
return QUrl::fromLocalFile(sub.absoluteFilePath(subMatches.first()));
}
}
}
}
return {};
}
QList<Game *> HeroicImporter::importGames()
{
QList<Game *> games;
games.append(importEpicGames());
games.append(importGOGGames());
games.append(importAmazonGames());
games.append(importSideloadApps());
return games;
}
QList<Game *> HeroicImporter::importSideloadApps()
{
QList<Game *> games;
const QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
return games;
}
const QString libraryPath = configPath + QStringLiteral("/sideload_apps/library.json");
QFile libraryFile(libraryPath);
if (!libraryFile.open(QIODevice::ReadOnly)) {
return games;
}
const QJsonDocument doc = QJsonDocument::fromJson(libraryFile.readAll());
QList<QJsonObject> entries;
if (doc.isArray()) {
const QJsonArray arr = doc.array();
entries.reserve(arr.size());
for (const QJsonValue &value : arr) {
if (value.isObject()) {
entries.append(value.toObject());
}
}
} else if (doc.isObject()) {
const QJsonObject root = doc.object();
const QStringList arrayKeys = {
QStringLiteral("games"),
QStringLiteral("apps"),
QStringLiteral("library"),
QStringLiteral("items"),
};
bool foundArray = false;
for (const QString &key : arrayKeys) {
const QJsonValue v = root.value(key);
if (v.isArray()) {
const QJsonArray arr = v.toArray();
entries.reserve(arr.size());
for (const QJsonValue &item : arr) {
if (item.isObject()) {
entries.append(item.toObject());
}
}
foundArray = true;
break;
}
}
if (!foundArray) {
// Some formats may store entries keyed by appName.
for (auto it = root.constBegin(); it != root.constEnd(); ++it) {
if (it.value().isObject()) {
QJsonObject obj = it.value().toObject();
if (!obj.contains(QStringLiteral("app_name")) && !obj.contains(QStringLiteral("appName"))) {
obj.insert(QStringLiteral("app_name"), it.key());
}
entries.append(obj);
}
}
}
} else {
return games;
}
for (const QJsonObject &obj : entries) {
QString appName = obj.value(QStringLiteral("app_name")).toString();
if (appName.isEmpty()) {
appName = obj.value(QStringLiteral("appName")).toString();
}
if (appName.isEmpty()) {
appName = obj.value(QStringLiteral("id")).toString();
}
QString title = obj.value(QStringLiteral("title")).toString();
if (title.isEmpty()) {
title = obj.value(QStringLiteral("name")).toString();
}
if (appName.isEmpty() || title.isEmpty()) {
continue;
}
QString launchCommand = obj.value(QStringLiteral("launchCommand")).toString();
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("launch_command")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("command")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("exec")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("exe")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("executable")).toString();
}
if (launchCommand.isEmpty()) {
launchCommand = obj.value(QStringLiteral("selectedExe")).toString();
}
QString workingDirectory = obj.value(QStringLiteral("workingDirectory")).toString();
if (workingDirectory.isEmpty()) {
workingDirectory = obj.value(QStringLiteral("install_path")).toString();
}
if (workingDirectory.isEmpty()) {
workingDirectory = obj.value(QStringLiteral("installPath")).toString();
}
bool isInstalled = obj.value(QStringLiteral("installed")).toBool(true);
if (obj.contains(QStringLiteral("is_installed"))) {
isInstalled = obj.value(QStringLiteral("is_installed")).toBool();
}
if (obj.contains(QStringLiteral("isInstalled"))) {
isInstalled = obj.value(QStringLiteral("isInstalled")).toBool();
}
Game *game = new Game(QStringLiteral("heroic-sideload-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Sideload)"));
game->setPlatformId(appName);
if (!workingDirectory.isEmpty()) {
game->setWorkingDirectory(workingDirectory);
}
game->setInstalled(isInstalled);
// We don't know if Heroic's CLI can always launch sideload apps consistently.
// Prefer an explicit command from the library file; fallback to Heroic's CLI if absent.
if (!launchCommand.isEmpty()) {
game->setLaunchCommand(launchCommand);
} else {
game->setLaunchCommand(launchCommandForApp(appName));
}
QString cover = obj.value(QStringLiteral("art_square")).toString();
if (cover.isEmpty()) {
cover = obj.value(QStringLiteral("artSquare")).toString();
}
if (cover.isEmpty()) {
cover = obj.value(QStringLiteral("art_cover")).toString();
}
if (cover.isEmpty()) {
cover = obj.value(QStringLiteral("artCover")).toString();
}
QUrl coverUrl;
if (!cover.isEmpty()) {
if (cover.startsWith(QLatin1String("/"))) {
coverUrl = QUrl::fromLocalFile(cover);
} else {
coverUrl = QUrl(cover);
}
} else {
coverUrl = extractCoverUrl(obj);
}
coverUrl = preferCachedCoverUrl(coverUrl);
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("sideload"));
}
if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl);
}
games.append(game);
}
return games;
}
QList<Game *> HeroicImporter::importEpicGames()
{
QList<Game *> games;
QString configPath = findHeroicConfigPath();
QString legendaryConfigPath = findLegendaryConfigPath();
if (configPath.isEmpty() && legendaryConfigPath.isEmpty()) {
return games;
}
// Check for installed.json
QStringList installedPaths;
if (!legendaryConfigPath.isEmpty()) {
installedPaths.append(legendaryConfigPath + QStringLiteral("/installed.json"));
}
if (!configPath.isEmpty()) {
installedPaths.append(configPath + QStringLiteral("/legendaryConfig/legendary/installed.json"));
installedPaths.append(configPath + QStringLiteral("/store_cache/legendary_library"));
installedPaths.append(configPath + QStringLiteral("/store_cache/legendary_library.json"));
installedPaths.append(configPath + QStringLiteral("/store/legendary_library"));
installedPaths.append(configPath + QStringLiteral("/store/legendary_library.json"));
}
QFile installedFile;
for (const QString &path : installedPaths) {
installedFile.setFileName(path);
if (installedFile.open(QIODevice::ReadOnly)) {
break;
}
}
if (!installedFile.isOpen()) {
return games;
}
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
if (doc.isObject()) {
QJsonObject obj = doc.object();
int current = 0;
int total = obj.keys().count();
for (const QString &appName : obj.keys()) {
QJsonObject gameObj = obj[appName].toObject();
QString title = gameObj[QStringLiteral("title")].toString();
if (title.isEmpty()) {
title = appName;
}
QString installPath = gameObj[QStringLiteral("install_path")].toString();
bool isInstalled = !installPath.isEmpty();
Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Epic)"));
game->setPlatformId(appName);
game->setLaunchCommand(launchCommandForApp(appName));
game->setWorkingDirectory(installPath);
game->setInstalled(isInstalled);
// Developer/Publisher info
QString developer = gameObj[QStringLiteral("developer")].toString();
QString publisher = gameObj[QStringLiteral("publisher")].toString();
game->setDeveloper(developer);
game->setPublisher(publisher);
QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("epic"));
}
if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl);
}
games.append(game);
current++;
Q_EMIT importProgress(current, total);
}
} else if (doc.isArray()) {
QJsonArray arr = doc.array();
int current = 0;
int total = arr.count();
for (const QJsonValue &value : arr) {
QJsonObject gameObj = value.toObject();
QString appName = gameObj[QStringLiteral("app_name")].toString();
QString title = gameObj[QStringLiteral("title")].toString();
if (appName.isEmpty() || title.isEmpty())
continue;
bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool();
Game *game = new Game(QStringLiteral("heroic-epic-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Epic)"));
game->setPlatformId(appName);
game->setLaunchCommand(launchCommandForApp(appName));
game->setInstalled(isInstalled);
QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("epic"));
}
if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl);
}
games.append(game);
current++;
Q_EMIT importProgress(current, total);
}
}
return games;
}
QList<Game *> HeroicImporter::importGOGGames()
{
QList<Game *> games;
QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
return games;
}
// Check for GOG installed games / library cache
QStringList installedPaths = {
configPath + QStringLiteral("/gog_store/installed.json"),
configPath + QStringLiteral("/store_cache/gog_library"),
configPath + QStringLiteral("/store_cache/gog_library.json"),
configPath + QStringLiteral("/store/gog_library"),
configPath + QStringLiteral("/store/gog_library.json"),
};
QFile installedFile;
for (const QString &path : installedPaths) {
installedFile.setFileName(path);
if (installedFile.open(QIODevice::ReadOnly)) {
break;
}
}
if (!installedFile.isOpen()) {
return games;
}
QJsonDocument doc = QJsonDocument::fromJson(installedFile.readAll());
auto parseGames = [this, &games](const QJsonArray &arr) {
for (const QJsonValue &value : arr) {
QJsonObject gameObj = value.toObject();
QString appName = gameObj[QStringLiteral("app_name")].toString();
if (appName.isEmpty()) {
appName = gameObj[QStringLiteral("appName")].toString();
}
QString title = gameObj[QStringLiteral("title")].toString();
if (title.isEmpty()) {
title = gameObj[QStringLiteral("name")].toString();
}
if (appName.isEmpty() || title.isEmpty())
continue;
bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool() || gameObj[QStringLiteral("isInstalled")].toBool();
Game *game = new Game(QStringLiteral("heroic-gog-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (GOG)"));
game->setPlatformId(appName);
game->setLaunchCommand(launchCommandForApp(appName));
game->setInstalled(isInstalled);
QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("gog"));
}
if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl);
}
games.append(game);
}
};
if (doc.isArray()) {
parseGames(doc.array());
} else if (doc.isObject()) {
QJsonObject obj = doc.object();
if (obj.contains(QStringLiteral("games"))) {
parseGames(obj[QStringLiteral("games")].toArray());
}
}
return games;
}
QList<Game *> HeroicImporter::importAmazonGames()
{
QList<Game *> games;
QString configPath = findHeroicConfigPath();
if (configPath.isEmpty()) {
return games;
}
QStringList libraryPaths = {
configPath + QStringLiteral("/store_cache/nile_library"),
configPath + QStringLiteral("/store_cache/nile_library.json"),
configPath + QStringLiteral("/store/nile_library"),
configPath + QStringLiteral("/store/nile_library.json"),
};
QFile libraryFile;
for (const QString &path : libraryPaths) {
libraryFile.setFileName(path);
if (libraryFile.open(QIODevice::ReadOnly)) {
break;
}
}
if (!libraryFile.isOpen()) {
return games;
}
QJsonDocument doc = QJsonDocument::fromJson(libraryFile.readAll());
if (!doc.isArray()) {
return games;
}
QJsonArray arr = doc.array();
for (const QJsonValue &value : arr) {
QJsonObject gameObj = value.toObject();
QString appName = gameObj[QStringLiteral("app_name")].toString();
QString title = gameObj[QStringLiteral("title")].toString();
if (appName.isEmpty() || title.isEmpty())
continue;
bool isInstalled = gameObj[QStringLiteral("is_installed")].toBool();
Game *game = new Game(QStringLiteral("heroic-amazon-%1").arg(appName), title);
game->setPlatform(QStringLiteral("Heroic (Amazon)"));
game->setPlatformId(appName);
game->setLaunchCommand(launchCommandForApp(appName));
game->setInstalled(isInstalled);
QUrl coverUrl = preferCachedCoverUrl(extractCoverUrl(gameObj));
if (!coverUrl.isValid()) {
coverUrl = findCoverImage(appName, QStringLiteral("amazon"));
}
if (coverUrl.isValid()) {
game->setCoverUrl(coverUrl);
}
games.append(game);
}
return games;
}