(path, apiKey)
| 70 | } |
| 71 | |
| 72 | export const tmdbFetch = async (path, apiKey) => { |
| 73 | const localizedPath = withLanguage(path); |
| 74 | const cacheKey = `${apiKey}|${localizedPath}`; |
| 75 | const cached = _tmdbCache.get(cacheKey); |
| 76 | if (cached && Date.now() < cached.expiresAt) return cached.data; |
| 77 | |
| 78 | await _acquireSlot(); |
| 79 | |
| 80 | let res; |
| 81 | try { |
| 82 | res = await fetch(`${TMDB_BASE}${localizedPath}`, { |
| 83 | headers: { Authorization: `Bearer ${apiKey}` }, |
| 84 | }); |
| 85 | } catch { |
| 86 | _releaseSlot(); |
| 87 | _onUnreachable?.(); |
| 88 | throw new Error("TMDB unreachable"); |
| 89 | } finally { |
| 90 | // releaseSlot is called in the catch above for network errors; |
| 91 | // for successful responses it is called immediately below, before |
| 92 | // parsing, so the slot is held only during the actual in-flight |
| 93 | // request, not during res.json(). |
| 94 | } |
| 95 | |
| 96 | _releaseSlot(); |
| 97 | |
| 98 | if (res.status === 401 || res.status === 403) { |
| 99 | _onAuthError?.(); |
| 100 | throw new Error(`TMDB ${res.status}`); |
| 101 | } |
| 102 | |
| 103 | if (!res.ok) throw new Error(`TMDB ${res.status}`); |
| 104 | const data = await res.json(); |
| 105 | _tmdbCache.set(cacheKey, { data, expiresAt: Date.now() + TMDB_CACHE_TTL }); |
| 106 | |
| 107 | // Evict stale entries to prevent unbounded memory growth |
| 108 | if (_tmdbCache.size > 80) { |
| 109 | const now = Date.now(); |
| 110 | for (const [k, v] of _tmdbCache) { |
| 111 | if (now >= v.expiresAt) _tmdbCache.delete(k); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | return data; |
| 116 | }; |
| 117 | |
| 118 | // Documentation: |
| 119 | // https://www.videasy.to/docs |
no test coverage detected