/ app / utils / video / next-video-pretest.js
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;