next-video-pretest.js
1 import * as videoRouter from '../../services/video/router.js'; 2 import * as videoSources from '../../services/video/sources.js'; 3 import * as providerMetrics from '../../services/metrics/provider-metrics.js'; 4 import { LRUCache } from '../../utils/caching/lru-cache.js'; 5 import { createLogger } from '../../utils/debug/logger.js'; 6 import AppConstants from '../../config/constants.js'; 7 8 const logger = createLogger('NextVideoPretest'); 9 10 const CACHE_MAX_SIZE = 20; 11 const CACHE_MAX_AGE_MS = 5 * 60 * 1000; 12 13 const PROVIDER_CONFIG = AppConstants.PROVIDERS.CONFIG; 14 const PROVIDER_KEYS = Object.keys(PROVIDER_CONFIG); 15 const MAX_PROVIDERS_TO_TEST = 5; 16 17 const testResultsCache = new LRUCache(CACHE_MAX_SIZE, CACHE_MAX_AGE_MS); 18 19 const pendingTestPromises = new Map(); 20 21 export async function pretestNextVideo(currentIndex) { 22 try { 23 const sourceList = videoSources.getSourceList(); 24 if (!sourceList || sourceList.length <= 1) { 25 logger.info('Not enough videos to pretest next video'); 26 return []; 27 } 28 29 const nextIndex = (currentIndex + 1) % sourceList.length; 30 const nextVideo = sourceList[nextIndex]; 31 32 if (!nextVideo?.cid) { 33 logger.warn('Next video has no CID, cannot pretest'); 34 return []; 35 } 36 37 const nextCid = nextVideo.cid; 38 const nextAltCid = nextVideo.altcid || ''; 39 40 logger.info(`Pretesting next video: ${nextCid.slice(0, 8)} (index ${nextIndex})`); 41 42 // Check if there's already a test in progress for this CID 43 if (pendingTestPromises.has(nextCid)) { 44 logger.info(`Using existing test promise for CID ${nextCid.slice(0, 8)}`); 45 return await pendingTestPromises.get(nextCid); 46 } 47 48 const testPromise = _preemptivelyTestProviders(nextCid, nextAltCid).finally(() => { 49 pendingTestPromises.delete(nextCid); 50 }); 51 52 pendingTestPromises.set(nextCid, testPromise); 53 54 const testResults = await testPromise; 55 56 if (testResults?.rankedProviders && testResults.rankedProviders.length > 0) { 57 logger.info( 58 `Pretested ${testResults.rankedProviders.length} providers for next video: ${testResults.rankedProviders.join(', ')}`, 59 ); 60 61 testResultsCache.set(nextCid, testResults); 62 63 return testResults.rankedProviders; 64 } 65 logger.info('No providers pretested for next video'); 66 return []; 67 } catch (error) { 68 logger.warn('Error pretesting next video:', error); 69 return []; 70 } 71 } 72 73 export function getTestResults(cid) { 74 if (!cid) { 75 logger.debug('getTestResults called with no CID'); 76 return null; 77 } 78 79 const results = testResultsCache.get(cid); 80 if (!results) { 81 logger.debug(`No test results found for CID ${cid.slice(0, 8)}`); 82 return null; 83 } 84 85 results.age = Date.now() - results.timestamp; 86 87 const now = Date.now(); 88 if (!results._lastLogTime || now - results._lastLogTime > 30000) { 89 if (results.responseTimeData && Object.keys(results.responseTimeData).length > 0) { 90 logger.debug( 91 `Found response time data for CID ${cid.slice(0, 8)}:`, 92 Object.entries(results.responseTimeData) 93 .map(([provider, time]) => `${provider}: ${Math.round(time)}ms`) 94 .join(', '), 95 ); 96 } 97 results._lastLogTime = now; 98 } 99 100 return results; 101 } 102 103 export function clearTestResults() { 104 testResultsCache.clear(); 105 logger.info('Test results cache cleared'); 106 } 107 108 export function getCacheStats() { 109 return { 110 resultsCache: { 111 size: testResultsCache.size, 112 maxSize: testResultsCache.maxSize, 113 maxAgeMs: CACHE_MAX_AGE_MS, 114 }, 115 pendingTests: { 116 size: pendingTestPromises.size, 117 cids: Array.from(pendingTestPromises.keys()).map(cid => `${cid.slice(0, 8)}...`), 118 }, 119 }; 120 } 121 122 async function _preemptivelyTestProviders(cid, altcid = '') { 123 if (!cid) { 124 logger.warn('No CID provided for preemptive testing'); 125 return null; 126 } 127 128 try { 129 logger.info(`Running preemptive tests for CID ${cid.slice(0, 8)}`); 130 const now = Date.now(); 131 132 const eligibleProviders = PROVIDER_KEYS.filter(key => !PROVIDER_CONFIG[key]?.isLivestreamOnly); 133 134 const prioritizedProviders = [...eligibleProviders].sort((a, b) => { 135 const metricsA = providerMetrics.getProviderMetrics(a); 136 const metricsB = providerMetrics.getProviderMetrics(b); 137 138 if (metricsA && metricsB) { 139 return (metricsB.healthScore || 0) - (metricsA.healthScore || 0); 140 } 141 142 if (metricsA) { 143 return -1; 144 } 145 if (metricsB) { 146 return 1; 147 } 148 149 return 0; 150 }); 151 152 const providersToTest = prioritizedProviders.slice(0, MAX_PROVIDERS_TO_TEST); 153 154 if (providersToTest.length === 0) { 155 logger.warn('No providers available for testing'); 156 return null; 157 } 158 159 const testPromises = providersToTest.map(providerKey => 160 _testSingleProvider(providerKey, cid, altcid, now), 161 ); 162 163 const results = await Promise.all(testPromises); 164 165 const successfulResults = results.filter( 166 result => result?.available && Number.isFinite(result.responseTime), 167 ); 168 169 if (successfulResults.length === 0) { 170 logger.info(`No successful provider tests for CID ${cid.slice(0, 8)}`); 171 return { 172 rankedProviders: [], 173 availabilityResults: {}, 174 responseTimeData: {}, 175 timestamp: now, 176 age: 0, 177 }; 178 } 179 180 successfulResults.sort((a, b) => a.responseTime - b.responseTime); 181 182 const rankedProviders = successfulResults.map(result => result.provider); 183 const availabilityResults = {}; 184 const responseTimeData = {}; 185 186 results.forEach(result => { 187 if (result?.provider) { 188 availabilityResults[result.provider] = result.available; 189 responseTimeData[result.provider] = result.responseTime; 190 } 191 }); 192 193 logger.info( 194 `Completed tests for CID ${cid.slice(0, 8)}, ${rankedProviders.length} successful providers`, 195 ); 196 197 if (Object.keys(responseTimeData).length > 0) { 198 logger.info( 199 'Response time data:', 200 Object.entries(responseTimeData) 201 .map(([provider, time]) => `${provider}: ${Math.round(time)}ms`) 202 .join(', '), 203 ); 204 } 205 206 return { 207 rankedProviders, 208 availabilityResults, 209 responseTimeData, 210 timestamp: now, 211 age: 0, 212 }; 213 } catch (error) { 214 logger.error('Error in preemptive testing:', error); 215 return null; 216 } 217 } 218 219 async function _testSingleProvider(providerKey, cid, altcid, now) { 220 try { 221 const providerBackoffState = videoRouter.getProviderBackoffState(); 222 const backoffData = providerBackoffState[providerKey]; 223 if (backoffData) { 224 return { 225 provider: providerKey, 226 available: false, 227 responseTime: Number.POSITIVE_INFINITY, 228 skipped: true, 229 }; 230 } 231 232 const url = videoRouter.getProviderUrl(providerKey, cid, altcid); 233 if (!url) { 234 return { 235 provider: providerKey, 236 available: false, 237 responseTime: Number.POSITIVE_INFINITY, 238 errorType: 'invalid_url', 239 }; 240 } 241 242 const startTime = performance.now(); 243 const controller = new AbortController(); 244 const timeout = 3000; 245 const timeoutId = setTimeout(() => controller.abort(), timeout); 246 247 try { 248 const response = await fetch(url, { 249 method: 'HEAD', 250 mode: 'cors', 251 cache: 'no-store', 252 253 redirect: 'follow', 254 signal: controller.signal, 255 priority: 'low', 256 }); 257 258 clearTimeout(timeoutId); 259 const responseTime = performance.now() - startTime; 260 const success = response.status >= 200 && response.status < 300; 261 262 providerMetrics.recordResponseTime(providerKey, responseTime, success); 263 264 return { 265 provider: providerKey, 266 available: success, 267 responseTime: success ? responseTime : Number.POSITIVE_INFINITY, 268 status: response.status, 269 }; 270 } catch (error) { 271 clearTimeout(timeoutId); 272 const responseTime = performance.now() - startTime; 273 const errorType = videoRouter.categorizeError(error); 274 275 providerMetrics.recordResponseTime( 276 providerKey, 277 errorType === 'timeout' ? timeout : responseTime, 278 false, 279 ); 280 281 logger.debug(`Provider ${providerKey} test failed: ${errorType}`); 282 283 return { 284 provider: providerKey, 285 available: false, 286 responseTime: Number.POSITIVE_INFINITY, 287 errorType, 288 }; 289 } 290 } catch (error) { 291 logger.warn(`Error testing provider ${providerKey}:`, error); 292 return { 293 provider: providerKey, 294 available: false, 295 responseTime: Number.POSITIVE_INFINITY, 296 errorType: 'exception', 297 }; 298 } 299 } 300 301 export default pretestNextVideo;