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; }