metadata-service.js 12 KB

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