Ctrl K
ComunidadGuías…

server/src/steam_artwork.cpp

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/** @file src/steam_artwork.cpp */
#include "steam_artwork.h"
#include "provider_scan_protocol.h"

#include <algorithm>
#include <array>
#include <cctype>
#include <chrono>
#include <cstring>
#include <fstream>
#include <iterator>
#include <limits>
#include <optional>
#include <sstream>
#include <thread>

#ifdef SUNSHINE_STEAM_ARTWORK_NETWORK
  #include <curl/curl.h>
  #include "httpcommon.h"
#endif

extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/error.h>
#include <libavutil/imgutils.h>
#include <libavutil/pixfmt.h>
#include <libswscale/swscale.h>
}

#ifdef ARTLIGHT_STEAM_ARTWORK_IMAGE_LIBS
extern "C" {
#include <jpeglib.h>
#include <png.h>
#include <webp/decode.h>
}
#include <setjmp.h>
#endif

#ifdef _WIN32
  #include <windows.h>
#endif

namespace fs = std::filesystem;

namespace {
  constexpr std::uint8_t png_signature[] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'};
  constexpr std::size_t max_remote_bytes = 16U * 1024U * 1024U;
  constexpr int full_portrait_width = 600;
  constexpr int full_portrait_height = 900;

  fs::path sidecar_path(const fs::path &output) {
    return output.string() + ".meta";
  }

  bool regular(const fs::path &path) {
    std::error_code ec;
    return fs::is_regular_file(path, ec) && !ec;
  }

  bool valid_png(const fs::path &path) {
    std::ifstream input(path, std::ios::binary);
    std::array<std::uint8_t, sizeof(png_signature)> bytes {};
    if (!input.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
      return false;
    }
    return std::equal(bytes.begin(), bytes.end(), std::begin(png_signature));
  }

  struct image_dimensions_t {
    int width = 0;
    int height = 0;
  };

  std::optional<image_dimensions_t> png_dimensions(const std::vector<std::uint8_t> &bytes) {
    if (bytes.size() < 24 || !std::equal(std::begin(png_signature), std::end(png_signature), bytes.begin()) ||
        std::memcmp(bytes.data() + 12, "IHDR", 4) != 0) {
      return std::nullopt;
    }
    const auto read_u32 = [&](std::size_t offset) {
      return (static_cast<std::uint32_t>(bytes[offset]) << 24) |
             (static_cast<std::uint32_t>(bytes[offset + 1]) << 16) |
             (static_cast<std::uint32_t>(bytes[offset + 2]) << 8) |
             static_cast<std::uint32_t>(bytes[offset + 3]);
    };
    const auto width = read_u32(16);
    const auto height = read_u32(20);
    if (width == 0 || height == 0 || width > static_cast<std::uint32_t>(std::numeric_limits<int>::max()) ||
        height > static_cast<std::uint32_t>(std::numeric_limits<int>::max())) {
      return std::nullopt;
    }
    return image_dimensions_t {static_cast<int>(width), static_cast<int>(height)};
  }

  std::uint32_t png_crc32(const std::uint8_t *bytes, std::size_t size) {
    std::uint32_t crc = 0xffffffffU;
    for (std::size_t index = 0; index < size; ++index) {
      crc ^= bytes[index];
      for (int bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xedb88320U & -(crc & 1U));
    }
    return ~crc;
  }

  bool valid_png_bytes(const std::vector<std::uint8_t> &bytes) {
    if (bytes.size() < sizeof(png_signature) ||
        !std::equal(std::begin(png_signature), std::end(png_signature), bytes.begin())) return false;
    std::size_t offset = sizeof(png_signature);
    while (offset + 12 <= bytes.size()) {
      const auto length = (static_cast<std::size_t>(bytes[offset]) << 24) |
                          (static_cast<std::size_t>(bytes[offset + 1]) << 16) |
                          (static_cast<std::size_t>(bytes[offset + 2]) << 8) |
                          static_cast<std::size_t>(bytes[offset + 3]);
      if (length > bytes.size() - offset - 12) return false;
      const auto crc = (static_cast<std::uint32_t>(bytes[offset + 8 + length]) << 24) |
                       (static_cast<std::uint32_t>(bytes[offset + 9 + length]) << 16) |
                       (static_cast<std::uint32_t>(bytes[offset + 10 + length]) << 8) |
                       static_cast<std::uint32_t>(bytes[offset + 11 + length]);
      if (png_crc32(bytes.data() + offset + 4, length + 4) != crc) return false;
      const bool end = std::memcmp(bytes.data() + offset + 4, "IEND", 4) == 0;
      offset += length + 12;
      if (end) return length == 0 && offset == bytes.size();
    }
    return false;
  }

  std::optional<platf::steam::artwork::source_fingerprint_t> fingerprint(const fs::path &source) {
    std::error_code ec;
    if (!fs::is_regular_file(source, ec) || ec) return std::nullopt;
    auto canonical = fs::weakly_canonical(source, ec);
    if (ec) canonical = fs::absolute(source, ec);
    if (ec) return std::nullopt;
    const auto size = fs::file_size(canonical, ec);
    if (ec) return std::nullopt;
    const auto mtime = fs::last_write_time(canonical, ec).time_since_epoch().count();
    if (ec) return std::nullopt;
    return platf::steam::artwork::source_fingerprint_t {canonical.generic_string(), size, mtime};
  }

  bool read_meta(const fs::path &path, platf::steam::artwork::source_fingerprint_t &out) {
    std::ifstream input(path);
    if (!input) return false;
    std::string line;
    while (std::getline(input, line)) {
      const auto split = line.find('=');
      if (split == std::string::npos) continue;
      const auto key = line.substr(0, split);
      const auto value = line.substr(split + 1);
      try {
        if (key == "path") out.path = value;
        else if (key == "size") out.size = static_cast<std::uintmax_t>(std::stoull(value));
        else if (key == "mtime") out.mtime = static_cast<std::intmax_t>(std::stoll(value));
      } catch (...) {
        return false;
      }
    }
    return !out.path.empty();
  }

  std::string unique_suffix() {
    const auto now = std::chrono::steady_clock::now().time_since_epoch().count();
    const auto thread_id = std::hash<std::thread::id> {}(std::this_thread::get_id());
    return std::to_string(now) + "." + std::to_string(thread_id);
  }

  bool replace_atomically(const fs::path &temporary, const fs::path &target) {
#ifdef _WIN32
    return MoveFileExW(temporary.wstring().c_str(), target.wstring().c_str(),
                       MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
#else
    std::error_code ec;
    fs::rename(temporary, target, ec);
    return !ec;
#endif
  }

  bool write_meta(const fs::path &path, const platf::steam::artwork::source_fingerprint_t &fp) {
    const auto temporary = path.string() + ".tmp." + unique_suffix();
    {
      std::ofstream output(temporary, std::ios::trunc);
      if (!output) return false;
      output << "version=1\npath=" << fp.path << "\nsize=" << fp.size << "\nmtime=" << fp.mtime << "\n";
      output.flush();
      if (!output) {
        std::error_code ec;
        fs::remove(temporary, ec);
        return false;
      }
    }
    if (!replace_atomically(temporary, path)) {
      std::error_code ec;
      fs::remove(temporary, ec);
      return false;
    }
    return true;
  }

  std::vector<std::uint8_t> read_bytes(const fs::path &path) {
    std::ifstream input(path, std::ios::binary);
    if (!input) return {};
    return std::vector<std::uint8_t> {std::istreambuf_iterator<char>(input), {}};
  }

  std::vector<std::uint8_t> read_prefix(const fs::path &path, std::size_t limit = 1024U * 1024U) {
    std::ifstream input(path, std::ios::binary);
    if (!input) return {};
    std::vector<std::uint8_t> bytes(limit);
    input.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
    bytes.resize(static_cast<std::size_t>(input.gcount()));
    return bytes;
  }

  std::optional<image_dimensions_t> probe_dimensions(const fs::path &path) {
    const auto bytes = read_prefix(path);
    if (bytes.size() >= 24 && std::equal(std::begin(png_signature), std::end(png_signature), bytes.begin()) &&
        std::memcmp(bytes.data() + 12, "IHDR", 4) == 0) {
      const auto read_u32 = [&](std::size_t offset) {
        return (static_cast<std::uint32_t>(bytes[offset]) << 24) |
               (static_cast<std::uint32_t>(bytes[offset + 1]) << 16) |
               (static_cast<std::uint32_t>(bytes[offset + 2]) << 8) |
               static_cast<std::uint32_t>(bytes[offset + 3]);
      };
      const auto width = read_u32(16);
      const auto height = read_u32(20);
      if (width > 0 && height > 0 && width <= static_cast<std::uint32_t>(std::numeric_limits<int>::max()) &&
          height <= static_cast<std::uint32_t>(std::numeric_limits<int>::max())) {
        return image_dimensions_t {static_cast<int>(width), static_cast<int>(height)};
      }
      return std::nullopt;
    }
    if (bytes.size() >= 16 && std::memcmp(bytes.data(), "RIFF", 4) == 0 && std::memcmp(bytes.data() + 8, "WEBP", 4) == 0) {
      std::size_t chunk = 12;
      while (chunk + 8 <= bytes.size()) {
        const auto size = static_cast<std::size_t>(bytes[chunk + 4]) |
                          (static_cast<std::size_t>(bytes[chunk + 5]) << 8) |
                          (static_cast<std::size_t>(bytes[chunk + 6]) << 16) |
                          (static_cast<std::size_t>(bytes[chunk + 7]) << 24);
        if (chunk + 8 + size > bytes.size()) break;
        if (std::memcmp(bytes.data() + chunk, "VP8X", 4) == 0 && size >= 10) {
          const auto width = 1U + static_cast<std::uint32_t>(bytes[chunk + 8 + 4]) +
                             (static_cast<std::uint32_t>(bytes[chunk + 8 + 5]) << 8) +
                             (static_cast<std::uint32_t>(bytes[chunk + 8 + 6]) << 16);
          const auto height = 1U + static_cast<std::uint32_t>(bytes[chunk + 8 + 7]) +
                              (static_cast<std::uint32_t>(bytes[chunk + 8 + 8]) << 8) +
                              (static_cast<std::uint32_t>(bytes[chunk + 8 + 9]) << 16);
          return image_dimensions_t {static_cast<int>(width), static_cast<int>(height)};
        }
        chunk += 8 + size + (size & 1U);
      }
    }
    if (bytes.size() >= 2 && bytes[0] == 0xff && bytes[1] == 0xd8) {
      std::size_t pos = 2;
      while (pos + 4 <= bytes.size()) {
        if (bytes[pos++] != 0xff) continue;
        while (pos < bytes.size() && bytes[pos] == 0xff) ++pos;
        if (pos >= bytes.size()) break;
        const auto marker = bytes[pos++];
        if (marker == 0xd8 || marker == 0xd9) continue;
        if (marker == 0xda) break;
        const auto length = (static_cast<std::size_t>(bytes[pos]) << 8) | bytes[pos + 1];
        if (length < 2 || pos + length > bytes.size()) break;
        const bool sof = (marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) ||
                         (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf);
        if (sof && length >= 7) {
          const auto height = (static_cast<int>(bytes[pos + 3]) << 8) | bytes[pos + 4];
          const auto width = (static_cast<int>(bytes[pos + 5]) << 8) | bytes[pos + 6];
          if (width > 0 && height > 0) return image_dimensions_t {width, height};
        }
        pos += length;
      }
    }
    return std::nullopt;
  }

  AVCodecID codec_id_for(const fs::path &path, const std::vector<std::uint8_t> &bytes) {
    // Steam occasionally leaves a stale extension while replacing cache
    // files. Prefer the actual signature so a test/provider fixture and the
    // downloaded cache are validated consistently after an atomic rename.
    if (bytes.size() >= 8 && std::equal(bytes.begin(), bytes.begin() + 8, png_signature)) return AV_CODEC_ID_PNG;
    if (bytes.size() >= 2 && bytes[0] == 0xff && bytes[1] == 0xd8) return AV_CODEC_ID_MJPEG;
    if (bytes.size() >= 12 && std::memcmp(bytes.data(), "RIFF", 4) == 0 && std::memcmp(bytes.data() + 8, "WEBP", 4) == 0) {
      return AV_CODEC_ID_WEBP;
    }
    std::string ext = path.extension().string();
    std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    if (ext == ".jpg" || ext == ".jpeg") return AV_CODEC_ID_MJPEG;
    if (ext == ".png") return AV_CODEC_ID_PNG;
    if (ext == ".webp") return AV_CODEC_ID_WEBP;
    return AV_CODEC_ID_NONE;
  }

#ifdef ARTLIGHT_STEAM_ARTWORK_IMAGE_LIBS
  struct rgba_image_t {
    int width = 0;
    int height = 0;
    std::vector<std::uint8_t> pixels;
  };

  struct jpeg_error_t {
    jpeg_error_mgr base;
    jmp_buf jump;
  };

  void jpeg_error_exit(j_common_ptr info) {
    auto *error = reinterpret_cast<jpeg_error_t *>(info->err);
    longjmp(error->jump, 1);
  }

  std::optional<rgba_image_t> decode_jpeg(const std::vector<std::uint8_t> &bytes) {
    jpeg_decompress_struct decoder {};
    jpeg_error_t error {};
    decoder.err = jpeg_std_error(&error.base);
    error.base.error_exit = jpeg_error_exit;
    if (setjmp(error.jump) != 0) {
      jpeg_destroy_decompress(&decoder);
      return std::nullopt;
    }
    jpeg_create_decompress(&decoder);
    jpeg_mem_src(&decoder, const_cast<unsigned char *>(bytes.data()), bytes.size());
    jpeg_read_header(&decoder, TRUE);
    decoder.out_color_space = JCS_RGB;
    jpeg_start_decompress(&decoder);
    rgba_image_t image {static_cast<int>(decoder.output_width), static_cast<int>(decoder.output_height),
                        std::vector<std::uint8_t>(decoder.output_width * decoder.output_height * 4)};
    std::vector<std::uint8_t> row(decoder.output_width * 3);
    while (decoder.output_scanline < decoder.output_height) {
      unsigned char *scanline = row.data();
      jpeg_read_scanlines(&decoder, &scanline, 1);
      const auto y = decoder.output_scanline - 1;
      for (unsigned int x = 0; x < decoder.output_width; ++x) {
        const auto source = x * 3;
        const auto target = (y * decoder.output_width + x) * 4;
        image.pixels[target] = row[source];
        image.pixels[target + 1] = row[source + 1];
        image.pixels[target + 2] = row[source + 2];
        image.pixels[target + 3] = 255;
      }
    }
    jpeg_finish_decompress(&decoder);
    jpeg_destroy_decompress(&decoder);
    return image;
  }

  std::optional<rgba_image_t> decode_webp(const std::vector<std::uint8_t> &bytes) {
    int width = 0;
    int height = 0;
    auto *decoded = WebPDecodeRGBA(bytes.data(), bytes.size(), &width, &height);
    if (!decoded || width <= 0 || height <= 0) {
      if (decoded) WebPFree(decoded);
      return std::nullopt;
    }
    rgba_image_t image {width, height, std::vector<std::uint8_t>(decoded, decoded + width * height * 4)};
    WebPFree(decoded);
    return image;
  }

  std::optional<std::vector<std::uint8_t>> encode_png(const rgba_image_t &image) {
    png_structp writer = png_create_write_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);
    png_infop info = writer ? png_create_info_struct(writer) : nullptr;
    if (!writer || !info) {
      if (writer) png_destroy_write_struct(&writer, &info);
      return std::nullopt;
    }
    std::vector<std::uint8_t> output;
    if (setjmp(png_jmpbuf(writer)) != 0) {
      png_destroy_write_struct(&writer, &info);
      return std::nullopt;
    }
    png_set_write_fn(writer, &output,
      [](png_structp png, png_bytep bytes, png_size_t size) {
        auto &target = *static_cast<std::vector<std::uint8_t> *>(png_get_io_ptr(png));
        target.insert(target.end(), bytes, bytes + size);
      }, nullptr);
    png_set_IHDR(writer, info, image.width, image.height, 8, PNG_COLOR_TYPE_RGBA,
                 PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT);
    png_write_info(writer, info);
    for (int y = 0; y < image.height; ++y) {
      png_write_row(writer, const_cast<png_bytep>(image.pixels.data() + y * image.width * 4));
    }
    png_write_end(writer, info);
    png_destroy_write_struct(&writer, &info);
    return output;
  }

  std::optional<std::vector<std::uint8_t>> convert_with_image_libs(const fs::path &source,
                                                                     const std::vector<std::uint8_t> &bytes) {
    const auto id = codec_id_for(source, bytes);
    if (id == AV_CODEC_ID_PNG && bytes.size() >= sizeof(png_signature) &&
        std::equal(std::begin(png_signature), std::end(png_signature), bytes.begin())) return bytes;
    std::optional<rgba_image_t> image;
    if (id == AV_CODEC_ID_MJPEG) image = decode_jpeg(bytes);
    else if (id == AV_CODEC_ID_WEBP) image = decode_webp(bytes);
    return image ? encode_png(*image) : std::nullopt;
  }
#endif

  std::optional<std::vector<std::uint8_t>> convert_to_png(const fs::path &source) {
    const auto bytes = read_bytes(source);
    if (bytes.empty()) return std::nullopt;
    const auto id = codec_id_for(source, bytes);
    // A validated PNG is already in the client format. Preserve it directly
    // so minimal FFmpeg bundles do not need a PNG decoder and encoder merely
    // to copy existing Steam artwork.
    if (id == AV_CODEC_ID_PNG) {
      return valid_png_bytes(bytes) ? std::optional {bytes} : std::nullopt;
    }
#ifdef ARTLIGHT_STEAM_ARTWORK_IMAGE_LIBS
    if (const auto converted = convert_with_image_libs(source, bytes)) return converted;
#endif
    if (id == AV_CODEC_ID_NONE) return std::nullopt;
    const auto *decoder = avcodec_find_decoder(id);
    if (!decoder) return std::nullopt;
    AVCodecContext *decoder_context = avcodec_alloc_context3(decoder);
    AVPacket *input_packet = av_packet_alloc();
    AVFrame *decoded = av_frame_alloc();
    if (!decoder_context || !input_packet || !decoded) {
      avcodec_free_context(&decoder_context);
      av_packet_free(&input_packet);
      av_frame_free(&decoded);
      return std::nullopt;
    }
    auto fail_decode = [&]() -> std::optional<std::vector<std::uint8_t>> {
      avcodec_free_context(&decoder_context);
      av_packet_free(&input_packet);
      av_frame_free(&decoded);
      return std::nullopt;
    };
    const auto decoder_open = avcodec_open2(decoder_context, decoder, nullptr);
    const auto packet_alloc = av_new_packet(input_packet, static_cast<int>(bytes.size()));
    if (decoder_open < 0 || packet_alloc < 0) {
      return fail_decode();
    }
    std::memcpy(input_packet->data, bytes.data(), bytes.size());
    input_packet->pts = 0;
    const auto packet_result = avcodec_send_packet(decoder_context, input_packet);
    const auto frame_result = avcodec_receive_frame(decoder_context, decoded);
    if (packet_result < 0 || frame_result < 0 ||
        decoded->width <= 0 || decoded->height <= 0) {
      return fail_decode();
    }

    const auto *encoder = avcodec_find_encoder(AV_CODEC_ID_PNG);
    if (!encoder) return fail_decode();
    AVCodecContext *encoder_context = avcodec_alloc_context3(encoder);
    AVFrame *encoded_frame = av_frame_alloc();
    AVPacket *output_packet = av_packet_alloc();
    SwsContext *sws = nullptr;
    if (!encoder_context || !encoded_frame || !output_packet) {
      avcodec_free_context(&encoder_context);
      av_frame_free(&encoded_frame);
      av_packet_free(&output_packet);
      return fail_decode();
    }
    auto supports = [&](AVPixelFormat format) {
      const void *supported_configs = nullptr;
      int supported_config_count = 0;
      if (avcodec_get_supported_config(
            encoder_context,
            encoder,
            AV_CODEC_CONFIG_PIX_FORMAT,
            0,
            &supported_configs,
            &supported_config_count
          ) < 0 || !supported_configs) {
        return false;
      }
      const auto *formats = static_cast<const AVPixelFormat *>(supported_configs);
      for (int i = 0; i < supported_config_count; ++i) {
        if (formats[i] == format) return true;
      }
      return false;
    };
    const auto output_format = supports(AV_PIX_FMT_RGBA) ? AV_PIX_FMT_RGBA : AV_PIX_FMT_RGB24;
    encoder_context->width = decoded->width;
    encoder_context->height = decoded->height;
    encoder_context->pix_fmt = output_format;
    encoder_context->time_base = AVRational {1, 25};
    encoded_frame->format = output_format;
    encoded_frame->width = decoded->width;
    encoded_frame->height = decoded->height;
    const auto encoder_open = avcodec_open2(encoder_context, encoder, nullptr);
    const auto frame_buffer = av_frame_get_buffer(encoded_frame, 1);
    if (encoder_open < 0 || frame_buffer < 0) {
      avcodec_free_context(&encoder_context);
      av_frame_free(&encoded_frame);
      av_packet_free(&output_packet);
      return fail_decode();
    }
    sws = sws_getContext(decoded->width, decoded->height, static_cast<AVPixelFormat>(decoded->format),
                         decoded->width, decoded->height, output_format, SWS_BILINEAR, nullptr, nullptr, nullptr);
    const auto scaled = sws ? sws_scale(sws, decoded->data, decoded->linesize, 0, decoded->height,
                                         encoded_frame->data, encoded_frame->linesize) : 0;
    const auto send_frame = (sws && scaled > 0) ? avcodec_send_frame(encoder_context, encoded_frame) : AVERROR(EINVAL);
    if (!sws || scaled <= 0 || send_frame < 0) {
      sws_freeContext(sws);
      avcodec_free_context(&encoder_context);
      av_frame_free(&encoded_frame);
      av_packet_free(&output_packet);
      return fail_decode();
    }
    std::vector<std::uint8_t> output;
    auto receive_packets = [&]() {
      while (true) {
        const int result = avcodec_receive_packet(encoder_context, output_packet);
        if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) return result;
        if (result < 0) return result;
        output.insert(output.end(), output_packet->data, output_packet->data + output_packet->size);
        av_packet_unref(output_packet);
      }
    };
    const auto first_receive = receive_packets();
    if (first_receive == AVERROR(EAGAIN)) {
      // Flush delayed encoders as well. PNG normally emits immediately, but
      // this keeps the conversion contract correct for alternate FFmpeg
      // encoder implementations.
      if (avcodec_send_frame(encoder_context, nullptr) >= 0) receive_packets();
    }
    sws_freeContext(sws);
    avcodec_free_context(&encoder_context);
    av_frame_free(&encoded_frame);
    av_packet_free(&output_packet);
    avcodec_free_context(&decoder_context);
    av_packet_free(&input_packet);
    av_frame_free(&decoded);
    if (output.size() < sizeof(png_signature) || !std::equal(std::begin(png_signature), std::end(png_signature), output.begin())) {
      return std::nullopt;
    }
    return output;
  }

  bool write_output(const fs::path &target, const std::vector<std::uint8_t> &bytes) {
    const auto temporary = target.string() + ".tmp." + unique_suffix();
    {
      std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
      if (!output) return false;
      output.write(reinterpret_cast<const char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
      output.flush();
      if (!output) {
        std::error_code ec;
        fs::remove(temporary, ec);
        return false;
      }
    }
    if (!replace_atomically(temporary, target)) {
      std::error_code ec;
      fs::remove(temporary, ec);
      return false;
    }
    return true;
  }

  bool full_portrait(const fs::path &source) {
    const auto dimensions = probe_dimensions(source);
    return dimensions && dimensions->width >= full_portrait_width && dimensions->height >= full_portrait_height;
  }

  bool recent_failure_marker(const fs::path &marker) {
    std::error_code ec;
    if (!regular(marker)) return false;
    const auto modified = fs::last_write_time(marker, ec);
    if (ec) return false;
    using clock = decltype(modified)::clock;
    const auto age = clock::now() - modified;
    return age >= decltype(age)::zero() && age < std::chrono::minutes(10);
  }

  void mark_remote_failure(const fs::path &marker) {
    const std::vector<std::uint8_t> marker_bytes {'1'};
    write_output(marker, marker_bytes);
  }

#ifdef SUNSHINE_STEAM_ARTWORK_NETWORK
  struct curl_buffer_t {
    std::vector<std::uint8_t> bytes;
    bool overflow = false;
  };

  std::size_t curl_write(void *contents, std::size_t size, std::size_t count, void *user) {
    auto *buffer = static_cast<curl_buffer_t *>(user);
    if (count != 0 && size > max_remote_bytes / count) {
      buffer->overflow = true;
      return 0;
    }
    const auto total = size * count;
    if (total > max_remote_bytes - buffer->bytes.size()) {
      buffer->overflow = true;
      return 0;
    }
    const auto *begin = static_cast<const std::uint8_t *>(contents);
    buffer->bytes.insert(buffer->bytes.end(), begin, begin + total);
    return total;
  }

  std::optional<std::vector<std::uint8_t>> fetch_remote(const std::string &url) {
    CURL *curl = curl_easy_init();
    if (!curl) return std::nullopt;
    if (!http::configure_curl_tls(curl)) {
      curl_easy_cleanup(curl);
      return std::nullopt;
    }
    curl_buffer_t buffer;
    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
    curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, 10000L);
    curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L);
    curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 5L);
    curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
    // Never weaken certificate or hostname verification for artwork.
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
    curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
    curl_easy_setopt(curl, CURLOPT_USERAGENT, "ArtLight-Steam-Artwork/1.0");
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buffer);
    const auto result = curl_easy_perform(curl);
    long status = 0;
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
    curl_easy_cleanup(curl);
    if (result != CURLE_OK || buffer.overflow || status < 200 || status >= 300 || buffer.bytes.empty()) {
      return std::nullopt;
    }
    return std::move(buffer.bytes);
  }
#else
  std::optional<std::vector<std::uint8_t>> fetch_remote(const std::string &) {
    // Component tests inject a fetcher; builds without the application HTTP
    // stack remain offline by construction.
    return std::nullopt;
  }
#endif

  fs::path obtain_remote_portrait(std::uint32_t app_id, const fs::path &appdata,
                                  const platf::steam::artwork::remote_fetcher_t &injected_fetcher) {
    if (app_id == 0) return {};
    const auto cache = platf::steam::artwork::remote_cache_path(appdata, app_id);
    const auto failure = cache.string() + ".failed";
    std::error_code ec;
    fs::create_directories(cache.parent_path(), ec);
    // Steam's CDN asset is immutable for the app ID in practice. Treat the
    // validated file as a stable source and fetch only when it is absent or
    // unusable; the 30-second catalog poll therefore never redownloads it.
    if (regular(cache) && full_portrait(cache)) return cache;
    if (recent_failure_marker(failure)) return {};

    const auto fetcher = injected_fetcher ? injected_fetcher : fetch_remote;
    const auto bytes = fetcher(platf::steam::artwork::remote_portrait_url(app_id));
    if (!bytes || bytes->empty() || bytes->size() > max_remote_bytes) {
      mark_remote_failure(failure);
      return {};
    }

    const auto temporary = cache.string() + ".download." + unique_suffix();
    if (!write_output(temporary, *bytes)) {
      mark_remote_failure(failure);
      return {};
    }
    const auto downloaded = read_bytes(temporary);
    const auto downloaded_id = codec_id_for(temporary, downloaded);
    const bool strict_png_ok = downloaded_id != AV_CODEC_ID_PNG || valid_png_bytes(downloaded);
    const auto converted = strict_png_ok ? convert_to_png(temporary) : std::nullopt;
    const auto dimensions = converted ? png_dimensions(*converted) : std::nullopt;
    if (!dimensions || dimensions->width < full_portrait_width || dimensions->height < full_portrait_height ||
        !replace_atomically(temporary, cache)) {
      fs::remove(temporary, ec);
      mark_remote_failure(failure);
      return {};
    }
    fs::remove(failure, ec);
    return cache;
  }
}  // namespace

namespace platf::steam::artwork {
  std::optional<std::vector<std::uint8_t>> export_png(const fs::path &source) {
    std::error_code ec;
    if (!fs::is_regular_file(source, ec) || fs::file_size(source, ec) > max_remote_bytes || ec) return std::nullopt;
    const auto png = convert_to_png(source);
    if (!png || png->size() > max_remote_bytes) return std::nullopt;
    return png;
  }

  bool import_png(const std::vector<std::uint8_t> &bytes, const fs::path &output) {
    if (bytes.size() > max_remote_bytes || !valid_png_bytes(bytes)) return false;
    const auto dimensions = png_dimensions(bytes);
    if (!dimensions || dimensions->width > 4096 || dimensions->height > 4096) return false;
    std::error_code ec;
    fs::create_directories(output.parent_path(), ec);
    return !ec && write_output(output, bytes);
  }

  fs::path session_cover(std::string_view provider, std::uint64_t id,
                         const std::string &revision, const fs::path &output, session_fetcher_t fetcher) {
#if defined(__linux__)
    if (revision.empty() || id == 0 || (provider != "steam" && provider != "lutris")) return {};
    const auto marker = output.string() + ".session-meta";
    std::ifstream metadata(marker);
    std::string previous;
    std::getline(metadata, previous);
    if (previous == revision && regular(output) && valid_png(output)) return output;
    const auto request = "provider-" + std::string(provider) + "-artwork:" + std::to_string(id);
    std::optional<std::vector<std::uint8_t>> bytes;
    if (fetcher) {
      bytes = fetcher(request);
    } else {
      const auto payload = provider_scan::detail::capture_command(
        "/usr/libexec/vibeshine/artlight-session-exec", request,
        {std::chrono::duration_cast<std::chrono::milliseconds>(provider_scan::command_timeout), max_remote_bytes});
      if (payload) bytes.emplace(payload->begin(), payload->end());
    }
    if (bytes && import_png(*bytes, output)) {
      write_output(marker, std::vector<std::uint8_t>(revision.begin(), revision.end()));
      return output;
    }
    // A transient session transition must not discard the last usable cover.
    if (regular(output) && valid_png(output)) return output;
#endif
    return {};
  }

  std::string remote_portrait_url(std::uint32_t app_id) {
    return "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/" +
           std::to_string(app_id) + "/library_600x900_2x.jpg";
  }

  fs::path remote_cache_path(const fs::path &appdata, std::uint32_t app_id) {
    return appdata / "covers" / ("steam_" + std::to_string(app_id) + "_600x900_2x.jpg");
  }

  fs::path cache_path(const fs::path &appdata, std::uint32_t app_id) {
    return appdata / "covers" / ("steam_" + std::to_string(app_id) + ".png");
  }

  sync_result_t sync_to(const fs::path &source, const fs::path &output) {
    sync_result_t result;
    const auto fp = fingerprint(source);
    if (!fp) {
      std::error_code ec;
      fs::remove(sidecar_path(output), ec);
      return result;
    }
    result.fingerprint = *fp;
    std::error_code ec;
    fs::create_directories(output.parent_path(), ec);
    source_fingerprint_t cached;
    if (regular(output) && valid_png(output) && read_meta(sidecar_path(output), cached) && cached == *fp) {
      result.client_path = output;
      result.reused = true;
      return result;
    }
    const auto converted = convert_to_png(source);
    if (!converted || !write_output(output, *converted)) {
      fs::remove(sidecar_path(output), ec);
      return result;
    }
    result.client_path = output;
    result.converted = true;
    write_meta(sidecar_path(output), *fp);
    return result;
  }

  sync_result_t sync(std::uint32_t app_id, const fs::path &source, const fs::path &appdata) {
    return sync_to(source, cache_path(appdata, app_id));
  }

  void prepare(std::vector<game_t> &games, const fs::path &appdata, remote_fetcher_t fetcher) {
    for (auto &game : games) {
      game.artwork_client_path.clear();
      auto source = !game.artwork_path.empty() ? game.artwork_path : game.boxart_path;
      if (!game.session_artwork_revision.empty()) {
        source = session_cover("steam", game.app_id, game.session_artwork_revision,
                               appdata / "covers" / ("steam_" + std::to_string(game.app_id) + "_local.png"));
      }
      // Steam's local library_600x900.jpg is commonly 300x450. Never upscale
      // it: use the official fixed-origin 600x900 CDN asset when available,
      // retaining the local image as an offline fallback.
      fs::path effective_source = source;
      if (source.empty() || !full_portrait(source)) {
        const auto remote = obtain_remote_portrait(game.app_id, appdata, fetcher);
        if (!remote.empty()) effective_source = remote;
      }
      // Keep the last converted cover through transient Steam cache/CDN failures.
      const auto cached = cache_path(appdata, game.app_id);
      if (regular(cached) && valid_png_bytes(read_bytes(cached))) {
        game.artwork_client_path = cached;
      }
      if (effective_source.empty()) continue;
      if (!game.session_artwork_revision.empty() && effective_source == source) {
        // This PNG was already converted by the session worker. Do not run
        // desktop-user image decoders again inside the machine host.
        game.artwork_client_path = source;
        continue;
      }
      const auto result = sync(game.app_id, effective_source, appdata);
      if (!result.client_path.empty()) {
        game.artwork_client_path = result.client_path;
      }
    }
  }
}  // namespace platf::steam::artwork