metadata-service.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. /**
  2. * @fileoverview Metadata Service for fetching video information via IPC
  3. * @author GrabZilla Development Team
  4. * @version 2.1.0
  5. */
  6. /**
  7. * METADATA SERVICE
  8. *
  9. * Fetches video metadata (title, thumbnail, duration) from URLs using yt-dlp
  10. * via the Electron IPC bridge.
  11. *
  12. * Features:
  13. * - Async metadata fetching with timeout
  14. * - Caching to avoid duplicate requests
  15. * - Error handling and fallback
  16. * - Support for YouTube and Vimeo
  17. */
  18. class MetadataService {
  19. constructor() {
  20. this.cache = new Map();
  21. this.pendingRequests = new Map();
  22. this.timeout = 30000; // 30 second timeout
  23. this.maxRetries = 2; // Maximum retry attempts
  24. this.retryDelay = 2000; // 2 second delay between retries
  25. this.ipcAvailable = typeof window !== 'undefined' && window.IPCManager;
  26. }
  27. /**
  28. * Get video metadata from URL
  29. * @param {string} url - Video URL to fetch metadata for
  30. * @returns {Promise<Object>} Video metadata object
  31. */
  32. async getVideoMetadata(url) {
  33. if (!url || typeof url !== 'string') {
  34. throw new Error('Valid URL is required');
  35. }
  36. const normalizedUrl = this.normalizeUrl(url);
  37. // Check cache first
  38. if (this.cache.has(normalizedUrl)) {
  39. return this.cache.get(normalizedUrl);
  40. }
  41. // Check if request is already pending
  42. if (this.pendingRequests.has(normalizedUrl)) {
  43. return this.pendingRequests.get(normalizedUrl);
  44. }
  45. // Create new request
  46. const requestPromise = this.fetchMetadata(normalizedUrl);
  47. this.pendingRequests.set(normalizedUrl, requestPromise);
  48. try {
  49. const metadata = await requestPromise;
  50. // Cache the result
  51. this.cache.set(normalizedUrl, metadata);
  52. return metadata;
  53. } finally {
  54. // Clean up pending request
  55. this.pendingRequests.delete(normalizedUrl);
  56. }
  57. }
  58. /**
  59. * Fetch metadata from main process via IPC with retry logic
  60. * @private
  61. * @param {string} url - Normalized video URL
  62. * @param {number} retryCount - Current retry attempt (default: 0)
  63. * @returns {Promise<Object>} Metadata object
  64. */
  65. async fetchMetadata(url, retryCount = 0) {
  66. if (!this.ipcAvailable) {
  67. console.warn('IPC not available, returning fallback metadata');
  68. return this.getFallbackMetadata(url);
  69. }
  70. try {
  71. // Get cookie file from app state if available
  72. const cookieFile = window.appState?.config?.cookieFile || null;
  73. // Create timeout promise
  74. const timeoutPromise = new Promise((_, reject) => {
  75. setTimeout(() => reject(new Error('Metadata fetch timeout')), this.timeout);
  76. });
  77. // Race between fetch and timeout
  78. const metadata = await Promise.race([
  79. window.IPCManager.getVideoMetadata(url, cookieFile),
  80. timeoutPromise
  81. ]);
  82. // Validate and normalize metadata
  83. return this.normalizeMetadata(metadata, url);
  84. } catch (error) {
  85. console.error(`Error fetching metadata for ${url} (attempt ${retryCount + 1}/${this.maxRetries + 1}):`, error);
  86. // Retry if we haven't exceeded max retries
  87. if (retryCount < this.maxRetries) {
  88. console.log(`Retrying metadata fetch for ${url} in ${this.retryDelay}ms...`);
  89. // Wait before retrying
  90. await new Promise(resolve => setTimeout(resolve, this.retryDelay));
  91. // Recursive retry
  92. return this.fetchMetadata(url, retryCount + 1);
  93. }
  94. // Return fallback metadata after all retries exhausted
  95. console.warn(`All retry attempts exhausted for ${url}, using fallback`);
  96. return this.getFallbackMetadata(url);
  97. }
  98. }
  99. /**
  100. * Normalize metadata response
  101. * @private
  102. * @param {Object} metadata - Raw metadata from IPC
  103. * @param {string} url - Original URL
  104. * @returns {Object} Normalized metadata
  105. */
  106. normalizeMetadata(metadata, url) {
  107. return {
  108. title: metadata.title || this.extractTitleFromUrl(url),
  109. thumbnail: metadata.thumbnail || null,
  110. duration: this.formatDuration(metadata.duration) || '00:00',
  111. filesize: metadata.filesize || null,
  112. uploader: metadata.uploader || null,
  113. uploadDate: metadata.upload_date || null,
  114. description: metadata.description || null,
  115. viewCount: metadata.view_count || null,
  116. likeCount: metadata.like_count || null
  117. };
  118. }
  119. /**
  120. * Get fallback metadata when fetch fails
  121. * @private
  122. * @param {string} url - Video URL
  123. * @returns {Object} Fallback metadata
  124. */
  125. getFallbackMetadata(url) {
  126. return {
  127. title: this.extractTitleFromUrl(url),
  128. thumbnail: null,
  129. duration: '00:00',
  130. filesize: null,
  131. uploader: null,
  132. uploadDate: null,
  133. description: null,
  134. viewCount: null,
  135. likeCount: null
  136. };
  137. }
  138. /**
  139. * Extract title from URL as fallback
  140. * @private
  141. * @param {string} url - Video URL
  142. * @returns {string} Extracted or placeholder title
  143. */
  144. extractTitleFromUrl(url) {
  145. try {
  146. // Extract video ID for YouTube
  147. if (url.includes('youtube.com') || url.includes('youtu.be')) {
  148. const match = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
  149. if (match) {
  150. return `YouTube Video (${match[1]})`;
  151. }
  152. }
  153. // Extract video ID for Vimeo
  154. if (url.includes('vimeo.com')) {
  155. const match = url.match(/vimeo\.com\/(\d+)/);
  156. if (match) {
  157. return `Vimeo Video (${match[1]})`;
  158. }
  159. }
  160. return url;
  161. } catch (error) {
  162. return url;
  163. }
  164. }
  165. /**
  166. * Format duration from seconds to MM:SS or HH:MM:SS
  167. * @private
  168. * @param {number} seconds - Duration in seconds
  169. * @returns {string} Formatted duration string
  170. */
  171. formatDuration(seconds) {
  172. if (!seconds || isNaN(seconds)) {
  173. return '00:00';
  174. }
  175. const hours = Math.floor(seconds / 3600);
  176. const minutes = Math.floor((seconds % 3600) / 60);
  177. const secs = Math.floor(seconds % 60);
  178. if (hours > 0) {
  179. return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  180. } else {
  181. return `${minutes}:${secs.toString().padStart(2, '0')}`;
  182. }
  183. }
  184. /**
  185. * Normalize URL for caching
  186. * @private
  187. * @param {string} url - Video URL
  188. * @returns {string} Normalized URL
  189. */
  190. normalizeUrl(url) {
  191. if (window.URLValidator) {
  192. return window.URLValidator.normalizeUrl(url);
  193. }
  194. return url.trim();
  195. }
  196. /**
  197. * Clear cache for specific URL or all URLs
  198. * @param {string} [url] - Optional URL to clear from cache
  199. */
  200. clearCache(url = null) {
  201. if (url) {
  202. const normalizedUrl = this.normalizeUrl(url);
  203. this.cache.delete(normalizedUrl);
  204. } else {
  205. this.cache.clear();
  206. }
  207. }
  208. /**
  209. * Get cache statistics
  210. * @returns {Object} Cache stats
  211. */
  212. getCacheStats() {
  213. return {
  214. size: this.cache.size,
  215. pendingRequests: this.pendingRequests.size,
  216. urls: Array.from(this.cache.keys())
  217. };
  218. }
  219. /**
  220. * Prefetch metadata for multiple URLs (uses optimized batch extraction)
  221. * @param {string[]} urls - Array of URLs to prefetch
  222. * @returns {Promise<Object[]>} Array of metadata objects
  223. */
  224. async prefetchMetadata(urls) {
  225. if (!Array.isArray(urls)) {
  226. throw new Error('URLs must be an array');
  227. }
  228. // Use batch API for better performance (5-10x faster)
  229. if (urls.length > 1 && this.ipcAvailable && window.IPCManager.getBatchVideoMetadata) {
  230. return this.getBatchMetadata(urls);
  231. }
  232. // Fallback to individual requests for single URL or if batch API not available
  233. const promises = urls.map(url =>
  234. this.getVideoMetadata(url).catch(error => {
  235. console.warn(`Failed to prefetch metadata for ${url}:`, error);
  236. return this.getFallbackMetadata(url);
  237. })
  238. );
  239. return Promise.all(promises);
  240. }
  241. /**
  242. * Get metadata for multiple URLs in a single batch request (5-10x faster)
  243. * @param {string[]} urls - Array of URLs to fetch metadata for
  244. * @returns {Promise<Object[]>} Array of metadata objects with url property
  245. */
  246. async getBatchMetadata(urls) {
  247. if (!Array.isArray(urls) || urls.length === 0) {
  248. throw new Error('Valid URL array is required');
  249. }
  250. if (!this.ipcAvailable || !window.IPCManager.getBatchVideoMetadata) {
  251. console.warn('Batch metadata API not available, falling back to individual requests');
  252. return this.prefetchMetadata(urls);
  253. }
  254. try {
  255. console.log(`Fetching batch metadata for ${urls.length} URLs...`);
  256. const startTime = Date.now();
  257. // Normalize URLs
  258. const normalizedUrls = urls.map(url => this.normalizeUrl(url));
  259. // Check cache for existing metadata
  260. const cachedResults = [];
  261. const uncachedUrls = [];
  262. for (const url of normalizedUrls) {
  263. if (this.cache.has(url)) {
  264. const cached = this.cache.get(url);
  265. cachedResults.push({ ...cached, url }); // Add url to result
  266. } else {
  267. uncachedUrls.push(url);
  268. }
  269. }
  270. // If all URLs are cached, return immediately
  271. if (uncachedUrls.length === 0) {
  272. const duration = Date.now() - startTime;
  273. console.log(`All ${urls.length} URLs found in cache (${duration}ms)`);
  274. return cachedResults;
  275. }
  276. // Get cookie file from app state if available
  277. const cookieFile = window.appState?.config?.cookieFile || null;
  278. // Fetch uncached URLs in batch
  279. const batchResults = await window.IPCManager.getBatchVideoMetadata(uncachedUrls, cookieFile);
  280. // Cache the new results
  281. for (const result of batchResults) {
  282. const normalizedUrl = this.normalizeUrl(result.url);
  283. this.cache.set(normalizedUrl, result);
  284. }
  285. // Combine cached and new results, maintaining original order
  286. const allResults = normalizedUrls.map(url => {
  287. // First check cached results
  288. const cached = cachedResults.find(r => this.normalizeUrl(r.url) === url);
  289. if (cached) return cached;
  290. // Then check new results
  291. const fresh = batchResults.find(r => this.normalizeUrl(r.url) === url);
  292. if (fresh) return fresh;
  293. // Fallback if not found
  294. console.warn(`No metadata found for ${url}, using fallback`);
  295. return { ...this.getFallbackMetadata(url), url };
  296. });
  297. const duration = Date.now() - startTime;
  298. const avgTime = duration / urls.length;
  299. console.log(`Batch metadata complete: ${urls.length} URLs in ${duration}ms (${avgTime.toFixed(1)}ms avg/video, ${cachedResults.length} cached)`);
  300. return allResults;
  301. } catch (error) {
  302. console.error('Batch metadata extraction failed, falling back to individual requests:', error);
  303. // Fallback to individual requests on error
  304. const promises = urls.map(url =>
  305. this.getVideoMetadata(url).catch(err => {
  306. console.warn(`Failed to fetch metadata for ${url}:`, err);
  307. return { ...this.getFallbackMetadata(url), url };
  308. })
  309. );
  310. return Promise.all(promises);
  311. }
  312. }
  313. }
  314. // Create singleton instance
  315. const metadataService = new MetadataService();
  316. // Export for use in other modules
  317. if (typeof module !== 'undefined' && module.exports) {
  318. module.exports = metadataService;
  319. } else if (typeof window !== 'undefined') {
  320. window.MetadataService = metadataService;
  321. }