metadata-service.js 12 KB

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