247 lines
8.8 KiB
PHP
247 lines
8.8 KiB
PHP
<?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;
|
||
}
|