/ app / services / video / sources.js
sources.js
  1  import {
  2    formatDateWithSlashes,
  3    getDateWithoutSeparators,
  4    createLivestreamName,
  5  } from '../../utils/formatting/index.js';
  6  
  7  let allSources = [];
  8  let isInLivestreamMode = false;
  9  
 10  function isAbortError(error) {
 11    return (
 12      error?.name === 'AbortError' || (error instanceof DOMException && error?.name === 'AbortError')
 13    );
 14  }
 15  
 16  async function fetchWithTimeout(url, timeout, attempt, maxRetries) {
 17    const controller = new AbortController();
 18    const { signal } = controller;
 19    const timeoutId = setTimeout(() => {
 20      controller.abort();
 21    }, timeout);
 22  
 23    try {
 24      console.info(`Fetching video sources from ${url} (attempt ${attempt}/${maxRetries})`);
 25  
 26      const response = await fetch(url, {
 27        cache: 'no-store',
 28        signal,
 29      });
 30  
 31      clearTimeout(timeoutId);
 32  
 33      if (!response.ok) {
 34        throw new Error(`HTTP error ${response.status} fetching video sources from ${url}`);
 35      }
 36  
 37      const jsonData = await response.json();
 38      if (!Array.isArray(jsonData) || jsonData.length === 0) {
 39        throw new Error(`Invalid JSON format from ${url}: Expected an array of video sources.`);
 40      }
 41  
 42      return jsonData;
 43    } catch (error) {
 44      clearTimeout(timeoutId);
 45      throw error;
 46    }
 47  }
 48  
 49  async function handleFetchError(error, url, attempt, maxRetries, retryDelay) {
 50    if (isAbortError(error)) {
 51      console.warn(`Timeout fetching video sources from ${url} (attempt ${attempt}/${maxRetries})`);
 52    } else {
 53      console.warn(
 54        `Error fetching video sources from ${url} (attempt ${attempt}/${maxRetries}):`,
 55        error.message,
 56      );
 57    }
 58  
 59    if (attempt === maxRetries) {
 60      return false;
 61    }
 62  
 63    const delay = retryDelay * 1.5 ** (attempt - 1);
 64    await new Promise(resolve => {
 65      setTimeout(resolve, delay);
 66    });
 67  
 68    return true;
 69  }
 70  
 71  async function _fetchSourcesWithRetry(url, options = {}) {
 72    const { maxRetries = 3, retryDelay = 1000, timeout = 10_000 } = options;
 73    const resolvedUrl = new URL(url, window.location.href).href;
 74    let lastError;
 75  
 76    for (let attempt = 1; attempt <= maxRetries; attempt++) {
 77      try {
 78        return await fetchWithTimeout(resolvedUrl, timeout, attempt, maxRetries);
 79      } catch (error) {
 80        lastError = error;
 81  
 82        const shouldRetry = await handleFetchError(
 83          error,
 84          resolvedUrl,
 85          attempt,
 86          maxRetries,
 87          retryDelay,
 88        );
 89        if (!shouldRetry) {
 90          break;
 91        }
 92      }
 93    }
 94  
 95    if (isAbortError(lastError)) {
 96      throw new Error(`Timeout fetching video sources after ${maxRetries} attempts`);
 97    }
 98  
 99    throw lastError || new Error(`Failed to fetch video sources after ${maxRetries} attempts`);
100  }
101  
102  async function loadSourcesFromJson(url = 'videos.json', optionsOrSkipShuffle = {}) {
103    const options =
104      typeof optionsOrSkipShuffle === 'boolean'
105        ? { skipShuffle: optionsOrSkipShuffle }
106        : optionsOrSkipShuffle || {};
107    try {
108      let sourceData = [];
109  
110      sourceData = await _fetchSourcesWithRetry(url, {
111        maxRetries: 3,
112        retryDelay: 1000,
113        timeout: 10_000,
114      });
115  
116      const originalSourceData = [...sourceData];
117  
118      const isLivestreamData =
119        (sourceData.length > 0 &&
120          (sourceData[0].date !== undefined || sourceData[0].link !== undefined)) ||
121        url.includes('lives.json');
122  
123      if (isLivestreamData) {
124        console.info('Loading livestream data from', url);
125        isInLivestreamMode = true;
126  
127        const livestreamSources = sourceData
128          .filter(
129            item =>
130              item &&
131              (item.date !== undefined ||
132                (typeof item.link === 'string' && item.link.trim().length > 0)),
133          )
134          .map((item, index) => {
135            let formattedDate = '';
136            if (item.date) {
137              formattedDate = formatDateWithSlashes(item.date);
138            }
139  
140            // For livestream data, we use the date value directly
141            // This allows simplified streaming logic
142            return {
143              cid: String(item.date || ''), // Use date as the cid for livestream mode
144              date: formattedDate,
145              rawDate: item.date || 0, // Store raw date for sorting
146              index: item.index || index,
147              isLivestream: true, // Mark as livestream for special handling
148              link: item.link || '', // Keep link if it exists, otherwise empty string
149              name: formattedDate
150                ? createLivestreamName(item.date)
151                : typeof item.name === 'string' && item.name.trim().length > 0
152                  ? item.name.trim()
153                  : `Livestream ${index + 1}`,
154            };
155          });
156  
157        livestreamSources.sort((a, b) => {
158          return b.rawDate - a.rawDate;
159        });
160  
161        livestreamSources.forEach((source, index) => {
162          source.index = index;
163        });
164  
165        allSources = livestreamSources;
166      } else {
167        isInLivestreamMode = false;
168        allSources = sourceData
169          .filter(item => item && typeof item.cid === 'string' && item.cid.trim().length > 0)
170          .map((item, index) => {
171            const cid = item.cid.trim();
172            return {
173              altcid: item.altcid || '',
174              cid,
175              index: item.index || index + 1, // Preserve the original index from videos.json
176              name:
177                typeof item.name === 'string' && item.name.trim().length > 0
178                  ? item.name.trim()
179                  : `Video ${index + 1}`,
180            };
181          });
182      }
183  
184      if (allSources.length > 0) {
185        if (!allSources[0].isLivestream && !isInLivestreamMode) {
186          shuffleSources();
187        } else {
188          console.info('Skipping shuffle for livestream mode');
189        }
190  
191        const { requestedIndex = null, requestedDate = null } = options;
192  
193        if (requestedDate !== null && allSources[0]?.isLivestream) {
194          const dateStr = String(requestedDate);
195          const shuffledIndex = allSources.findIndex(source => {
196            if (source.date) {
197              const sourceDate = getDateWithoutSeparators(source.date);
198              return sourceDate === dateStr;
199            }
200            return false;
201          });
202  
203          if (shuffledIndex !== -1) {
204            console.info(`Making livestream with date ${requestedDate} the first in the playlist`);
205            makeVideoFirst(shuffledIndex);
206          } else {
207            console.warn(`Could not find livestream with date ${requestedDate} in sources`);
208          }
209        } else if (requestedIndex !== null) {
210          const matchingVideo = originalSourceData.find(video => video.index === requestedIndex);
211  
212          if (matchingVideo) {
213            const originalCid = matchingVideo.cid.trim();
214            const shuffledIndex = allSources.findIndex(source => source.cid === originalCid);
215  
216            if (shuffledIndex !== -1) {
217              console.info(
218                `Making video with index ${requestedIndex} (CID: ${originalCid}) the first in the playlist`,
219              );
220              makeVideoFirst(shuffledIndex);
221            } else {
222              console.warn(`Could not find video with CID ${originalCid} in shuffled sources`);
223            }
224          } else if (requestedIndex >= 1 && requestedIndex <= originalSourceData.length) {
225            const zeroBasedIndex = requestedIndex - 1;
226            const originalVideo = originalSourceData[zeroBasedIndex];
227  
228            if (originalVideo?.cid) {
229              const originalCid = originalVideo.cid.trim();
230              const shuffledIndex = allSources.findIndex(source => source.cid === originalCid);
231  
232              if (shuffledIndex !== -1) {
233                console.info(
234                  `Making video at position ${requestedIndex} (CID: ${originalCid}) the first in the playlist`,
235                );
236                makeVideoFirst(shuffledIndex);
237              } else {
238                console.warn(`Could not find video with CID ${originalCid} in shuffled sources`);
239              }
240            }
241          } else {
242            if (requestedIndex === originalSourceData.length + 1) {
243              console.info(
244                `Requested index ${requestedIndex} is out of bounds, using shuffled order`,
245              );
246            } else if (requestedIndex > originalSourceData.length + 1) {
247              console.warn(
248                `Requested index ${requestedIndex} is too large (max: ${originalSourceData.length}), using shuffled order`,
249              );
250            } else {
251              console.info(
252                `Requested index ${requestedIndex} not found in videos.json, using shuffled order`,
253              );
254            }
255          }
256        }
257  
258        return true;
259      }
260      return false;
261    } catch (error) {
262      allSources = [];
263      throw error;
264    }
265  }
266  
267  function getSourceCids() {
268    return allSources.map(source => source.cid);
269  }
270  
271  function getSourceList() {
272    return [...allSources];
273  }
274  
275  function getVideoInfoByIndex(index) {
276    if (index < 0 || index >= allSources.length) {
277      return null;
278    }
279    return allSources[index];
280  }
281  
282  function shuffleSources() {
283    if (allSources.length <= 1) {
284      return;
285    }
286  
287    if (allSources[0].isLivestream || isInLivestreamMode) {
288      console.info('Skipping shuffle for livestream data');
289      return;
290    }
291  
292    for (let i = allSources.length - 1; i > 0; i--) {
293      const j = Math.floor(Math.random() * (i + 1));
294  
295      [allSources[i], allSources[j]] = [allSources[j], allSources[i]];
296    }
297  
298    console.info('Playlist shuffled successfully');
299  }
300  
301  function makeVideoFirst(index) {
302    if (index < 0 || index >= allSources.length || allSources.length <= 1) {
303      console.warn(
304        `Invalid index ${index} for makeVideoFirst, allSources.length: ${allSources.length}`,
305      );
306      return false;
307    }
308  
309    if (index === 0) {
310      return true;
311    }
312  
313    const videoToMakeFirst = allSources[index];
314  
315    allSources.splice(index, 1);
316  
317    allSources.unshift(videoToMakeFirst);
318  
319    console.info(`Made video "${videoToMakeFirst.name}" the first in the playlist`);
320    return true;
321  }
322  
323  function getSourceCount() {
324    return allSources.length;
325  }
326  
327  function isLivestreamMode() {
328    return isInLivestreamMode;
329  }
330  
331  export {
332    loadSourcesFromJson,
333    getSourceCids,
334    getSourceList,
335    getVideoInfoByIndex,
336    shuffleSources,
337    makeVideoFirst,
338    getSourceCount,
339    isLivestreamMode,
340  };