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