Files
Thomas a184c31cca Software
v0.0..1
2025-10-05 14:58:05 +02:00

247 lines
8.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Twitch Helix helper med performance:
* - Persistent cURL + HTTP/2 + gzip
* - Fælles auth headers fra .env
* - Retry/backoff på 429/5xx (respekterer Retry-After)
* - Simpel filcache + ETag/Last-Modified
* - Pagineret GET helper
*/
const HELIX_BASE = 'https://api.twitch.tv/helix';
const DEFAULT_TIMEOUT = 5; // sek (kort for hurtigere side-loads)
const DEFAULT_RETRIES = 2; // ekstra forsøg udover første
const DEFAULT_BACKOFF_MS = 250; // start-backoff i ms
const MAX_BACKOFF_MS = 1500; // maks backoff i ms
/* ----------------------- ENV + AUTH ----------------------- */
function env_load(string $file): array {
$env = [];
if (!file_exists($file)) return $env;
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (strpos(ltrim($line), '#') === 0) continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, null);
if ($k !== null && $v !== null) $env[trim($k)] = trim($v);
}
return $env;
}
/** Returnerer [ok(bool), headers(array), err(array|null)] */
function twitch_auth_headers(): array {
$env = env_load(dirname(__DIR__) . '/.env');
$token = preg_replace('/^oauth:/i', '', $env['TWITCH_OAUTH'] ?? '');
$client = $env['TWITCH_CLIENT_ID'] ?? '';
if ($token === '' || $client === '') {
return [false, [], ['http' => 0, 'data' => ['error' => 'missing env: TWITCH_OAUTH / TWITCH_CLIENT_ID']]];
}
return [true, [
'Client-ID: ' . $client,
'Authorization: Bearer ' . $token,
'Accept: application/json',
], null];
}
/* ----------------------- CACHE ----------------------- */
function cache_dir(): string {
$dir = dirname(__DIR__) . '/data/cache';
if (!is_dir($dir)) @mkdir($dir, 0777, true);
return $dir;
}
function cache_path(string $key): string {
return cache_dir() . '/' . preg_replace('/[^a-z0-9_.-]/i', '_', $key) . '.json';
}
function cache_meta_path(string $key): string {
return cache_dir() . '/' . preg_replace('/[^a-z0-9_.-]/i', '_', $key) . '.meta.json';
}
/* ----------------------- CORE REQUEST ----------------------- */
function _curl_handle() {
static $ch = null;
if ($ch === null) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '', // gzip/deflate/br
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_TIMEOUT => DEFAULT_TIMEOUT,
CURLOPT_HEADER => true, // vi splitter headers/body selv
]);
}
return $ch;
}
/**
* Helix request med retry/backoff + ETag.
* @return array ['http'=>int, 'data'=>array, 'raw'=>string, 'headers'=>array]
*/
function helix_request(string $method, string $path, array $params = [], $payload = null, array $opts = []): array {
[$ok, $authHeaders, $err] = twitch_auth_headers();
if (!$ok) return $err;
$ch = _curl_handle();
$url = HELIX_BASE . $path . ($params ? '?' . http_build_query($params) : '');
$ttl = (int)($opts['ttl'] ?? 0);
$ckey = $opts['cache_key'] ?? ($path . '?' . http_build_query($params));
$useCache = (bool)($opts['use_cache'] ?? false);
// ETag/Last-Modified support
$metaFile = cache_meta_path('helix_meta_' . md5($ckey));
$meta = file_exists($metaFile) ? (json_decode((string)file_get_contents($metaFile), true) ?: []) : [];
$headers = $authHeaders;
if (!empty($meta['etag'])) $headers[] = 'If-None-Match: ' . $meta['etag'];
if (!empty($meta['last_modified'])) $headers[] = 'If-Modified-Since: ' . $meta['last_modified'];
// Sæt request
$set = [
CURLOPT_URL => $url,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_POSTFIELDS => null,
];
if (strtoupper($method) === 'POST') {
$set[CURLOPT_HTTPHEADER] = array_merge($headers, ['Content-Type: application/json']);
$set[CURLOPT_POSTFIELDS] = json_encode($payload ?? [], JSON_UNESCAPED_UNICODE);
}
curl_setopt_array($ch, $set);
$attempts = 1 + DEFAULT_RETRIES;
$backoff = DEFAULT_BACKOFF_MS;
do {
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($response === false) {
if (--$attempts > 0) { usleep(min($backoff, MAX_BACKOFF_MS) * 1000); $backoff *= 2; continue; }
return ['http' => 0, 'data' => ['error' => 'curl_error', 'code' => $errno, 'message' => $error]];
}
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$rawHeaders = substr($response, 0, $headerSize);
$rawBody = substr($response, $headerSize);
$hdrLines = preg_split('/\r\n/', trim($rawHeaders));
$hdrAssoc = [];
foreach ($hdrLines as $h) {
$p = strpos($h, ':');
if ($p !== false) {
$k = strtolower(trim(substr($h, 0, $p)));
$v = trim(substr($h, $p + 1));
$hdrAssoc[$k] = $v;
}
}
// Retry på 429/5xx
if ($status === 429 || ($status >= 500 && $status <= 599)) {
$retryAfter = (int)($hdrAssoc['retry-after'] ?? 0);
$sleepMs = max($retryAfter * 1000, min($backoff, MAX_BACKOFF_MS));
if (--$attempts > 0) { usleep($sleepMs * 1000); $backoff *= 2; continue; }
}
// 304 = brug cache hvis mulig
if ($status === 304 && $useCache) {
$cacheFile = cache_path('helix_' . md5($ckey) . '_ttl' . $ttl);
if (file_exists($cacheFile)) {
$cached = json_decode((string)file_get_contents($cacheFile), true);
if (is_array($cached)) {
$cached['headers'] = $hdrAssoc;
$cached['http'] = 200; // lever data som 200 fra cache
return $cached;
}
}
return ['http' => 304, 'data' => [], 'raw' => '', 'headers' => $hdrAssoc];
}
$data = json_decode($rawBody, true);
if (!is_array($data)) $data = [];
// Gem ETag/LM til senere
$etag = $hdrAssoc['etag'] ?? null;
$lm = $hdrAssoc['last-modified'] ?? null;
if ($etag || $lm) {
@file_put_contents($metaFile, json_encode(['etag' => $etag, 'last_modified' => $lm]));
}
$out = ['http' => $status, 'data' => $data, 'raw' => $rawBody, 'headers' => $hdrAssoc];
// Cache ved success
if ($useCache && $ttl > 0 && $status === 200) {
@file_put_contents(cache_path('helix_' . md5($ckey) . '_ttl' . $ttl), json_encode($out, JSON_UNESCAPED_UNICODE));
}
return $out;
} while ($attempts > 0);
}
/* ----------------------- PUBLIC WRAPPERS ----------------------- */
function helix_get(string $path, array $params = []): array {
return helix_request('GET', $path, $params, null, ['use_cache' => false]);
}
function helix_post(string $path, array $payload): array {
return helix_request('POST', $path, [], $payload, ['use_cache' => false]);
}
/** Cachet GET samme signatur som før. */
function helix_get_cached(string $path, array $params = [], int $ttl = 30): array {
$ckey = $path . '?' . http_build_query($params);
$file = cache_path('helix_' . md5($ckey) . '_ttl' . $ttl);
if (file_exists($file) && (time() - filemtime($file) < $ttl)) {
$cached = json_decode((string)file_get_contents($file), true);
if (is_array($cached)) return $cached;
}
return helix_request('GET', $path, $params, null, [
'ttl' => $ttl,
'cache_key' => $ckey,
'use_cache' => true,
]);
}
/** Pagineret helper samler alle sider i ét array. */
function helix_get_paginated(string $path, array $params = [], int $maxPages = 10, int $ttl = 0): array {
$all = [];
$cursor = null;
$pages = 0;
$http = 200;
do {
$pages++;
$p = $params;
if ($cursor) $p['after'] = $cursor;
$res = $ttl > 0
? helix_get_cached($path, $p, $ttl)
: helix_get($path, $p);
$http = $res['http'] ?? 0;
if ($http !== 200) break;
$chunk = $res['data']['data'] ?? [];
if (is_array($chunk)) $all = array_merge($all, $chunk);
$cursor = $res['data']['pagination']['cursor'] ?? null;
} while ($cursor && $pages < $maxPages);
return ['http' => $http, 'data' => $all, 'pages' => $pages];
}
/** Slår login op → Twitch user_id (cachet i 5 min) */
function get_user_id(string $login): ?string {
$r = helix_get_cached('/users', ['login' => $login], 300);
if (($r['http'] ?? 0) === 200 && !empty($r['data']['data'][0]['id'])) {
return (string)$r['data']['data'][0]['id'];
}
return null;
}