8chan YouTube Link Enhancer

Cleans up YouTube links, adds titles, optional thumbnail previews, and settings via STM.

  1. // ==UserScript==
  2. // @name 8chan YouTube Link Enhancer
  3. // @namespace nipah-scripts-8chan
  4. // @version 3.3.1
  5. // @description Cleans up YouTube links, adds titles, optional thumbnail previews, and settings via STM.
  6. // @author nipah, Gemini
  7. // @license MIT
  8. // @match https://8chan.moe/*
  9. // @match https://8chan.se/*
  10. // @grant GM.xmlHttpRequest
  11. // @grant GM.setValue
  12. // @grant GM.getValue
  13. // @grant GM_addStyle
  14. // @connect youtube.com
  15. // @connect i.ytimg.com
  16. // @run-at document-idle
  17. // ==/UserScript==
  18.  
  19. (async function() {
  20. 'use strict';
  21.  
  22. // --- Constants ---
  23. const SCRIPT_NAME = 'YTLE';
  24. const SCRIPT_ID = 'YTLE'; // Unique ID for Settings Tab Manager
  25. const CACHE_KEY_SETTINGS = 'ytleSettings';
  26. const CACHE_KEY_TITLES = 'ytleTitleCache';
  27.  
  28. const DEFAULTS = Object.freeze({
  29. CACHE_EXPIRY_DAYS: 7,
  30. SHOW_THUMBNAILS: false,
  31. API_DELAY_MS: 200,
  32. CACHE_CLEANUP_PROBABILITY: 0.1, // 10% chance per run
  33. THUMBNAIL_POPUP_ID: 'ytle-thumbnail-popup',
  34. THUMBNAIL_HIDE_DELAY_MS: 150,
  35. });
  36.  
  37. const REGEX = Object.freeze({
  38. YOUTUBE: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/|live\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:[?&#]|$)/i, // Simplified slightly, captures ID
  39. YOUTUBE_TRACKING_PARAMS: /[?&](si|feature|ref|fsi|source|utm_source|utm_medium|utm_campaign|gclid|gclsrc|fbclid)=[^&]+/gi,
  40. });
  41.  
  42. const URL_TEMPLATES = Object.freeze({
  43. OEMBED: "https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=VIDEO_ID&format=json",
  44. THUMBNAIL_WEBP: "https://i.ytimg.com/vi_webp/VIDEO_ID/maxresdefault.webp",
  45. // Fallback might be needed if maxresdefault webp fails often, e.g., mqdefault.jpg
  46. // THUMBNAIL_JPG_HQ: "https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg",
  47. });
  48.  
  49. const PLACEHOLDER_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // Transparent pixel
  50. const YOUTUBE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="#FF0000" d="M549.7 124.1c-6.3-23.7-24.9-42.4-48.6-48.6C456.5 64 288 64 288 64s-168.5 0-213.1 11.5c-23.7 6.3-42.4 24.9-48.6 48.6C16 168.5 16 256 16 256s0 87.5 10.3 131.9c6.3 23.7 24.9 42.4 48.6 48.6C119.5 448 288 448 288 448s168.5 0 213.1-11.5c23.7-6.3 42.4-24.9 48.6-48.6 10.3-44.4 10.3-131.9 10.3-131.9s0-87.5-10.3-131.9zM232 334.1V177.9L361 256 232 334.1z"/></svg>`;
  51.  
  52. // --- Utilities ---
  53. const Logger = {
  54. prefix: `[${SCRIPT_NAME}]`,
  55. log: (...args) => console.log(Logger.prefix, ...args),
  56. warn: (...args) => console.warn(Logger.prefix, ...args),
  57. error: (...args) => console.error(Logger.prefix, ...args),
  58. };
  59.  
  60. function delay(ms) {
  61. return new Promise(resolve => setTimeout(resolve, ms));
  62. }
  63.  
  64. function getVideoId(href) {
  65. if (!href) return null;
  66. const match = href.match(REGEX.YOUTUBE);
  67. return match ? match[1] : null;
  68. }
  69.  
  70. // --- Settings Manager ---
  71. class SettingsManager {
  72. constructor() {
  73. this.settings = {
  74. cacheExpiryDays: DEFAULTS.CACHE_EXPIRY_DAYS,
  75. showThumbnails: DEFAULTS.SHOW_THUMBNAILS
  76. };
  77. }
  78.  
  79. async load() {
  80. try {
  81. const loadedSettings = await GM.getValue(CACHE_KEY_SETTINGS, this.settings);
  82.  
  83. // Validate and merge loaded settings
  84. this.settings.cacheExpiryDays = (typeof loadedSettings.cacheExpiryDays === 'number' && Number.isInteger(loadedSettings.cacheExpiryDays) && loadedSettings.cacheExpiryDays > 0)
  85. ? loadedSettings.cacheExpiryDays
  86. : DEFAULTS.CACHE_EXPIRY_DAYS;
  87.  
  88. this.settings.showThumbnails = (typeof loadedSettings.showThumbnails === 'boolean')
  89. ? loadedSettings.showThumbnails
  90. : DEFAULTS.SHOW_THUMBNAILS;
  91.  
  92. Logger.log('Settings loaded:', this.settings);
  93. } catch (e) {
  94. Logger.warn('Failed to load settings, using defaults.', e);
  95. // Reset to defaults on error
  96. this.settings = {
  97. cacheExpiryDays: DEFAULTS.CACHE_EXPIRY_DAYS,
  98. showThumbnails: DEFAULTS.SHOW_THUMBNAILS
  99. };
  100. }
  101. }
  102.  
  103. async save() {
  104. try {
  105. // Ensure types before saving
  106. this.settings.cacheExpiryDays = Math.max(1, Math.floor(this.settings.cacheExpiryDays || DEFAULTS.CACHE_EXPIRY_DAYS));
  107. this.settings.showThumbnails = !!this.settings.showThumbnails;
  108.  
  109. await GM.setValue(CACHE_KEY_SETTINGS, this.settings);
  110. Logger.log('Settings saved:', this.settings);
  111. } catch (e) {
  112. Logger.error('Failed to save settings.', e);
  113. }
  114. }
  115.  
  116. get cacheExpiryDays() {
  117. return this.settings.cacheExpiryDays;
  118. }
  119.  
  120. set cacheExpiryDays(days) {
  121. const val = parseInt(days, 10);
  122. if (!isNaN(val) && val > 0) {
  123. this.settings.cacheExpiryDays = val;
  124. } else {
  125. Logger.warn(`Attempted to set invalid cacheExpiryDays: ${days}`);
  126. }
  127. }
  128.  
  129. get showThumbnails() {
  130. return this.settings.showThumbnails;
  131. }
  132.  
  133. set showThumbnails(value) {
  134. this.settings.showThumbnails = !!value;
  135. }
  136. }
  137.  
  138. // --- Title Cache ---
  139. class TitleCache {
  140. constructor(settingsManager) {
  141. this.settings = settingsManager; // Reference to settings
  142. this.cache = null; // Lazy loaded
  143. }
  144.  
  145. async _loadCache() {
  146. if (this.cache === null) {
  147. try {
  148. this.cache = await GM.getValue(CACHE_KEY_TITLES, {});
  149. } catch (e) {
  150. Logger.warn('Failed to load title cache:', e);
  151. this.cache = {}; // Use empty cache on error
  152. }
  153. }
  154. return this.cache;
  155. }
  156.  
  157. async _saveCache() {
  158. if (this.cache === null) return; // Don't save if never loaded
  159. try {
  160. await GM.setValue(CACHE_KEY_TITLES, this.cache);
  161. } catch (e) {
  162. Logger.warn('Failed to save title cache:', e);
  163. }
  164. }
  165.  
  166. async getTitle(videoId) {
  167. const cache = await this._loadCache();
  168. const item = cache[videoId];
  169. if (item && typeof item.expiry === 'number' && item.expiry > Date.now()) {
  170. return item.title;
  171. }
  172. // If expired or not found, remove old entry (if exists) and return null
  173. if (item) {
  174. delete cache[videoId];
  175. await this._saveCache(); // Save removal
  176. }
  177. return null;
  178. }
  179.  
  180. async setTitle(videoId, title) {
  181. if (!videoId || typeof title !== 'string') return;
  182. const cache = await this._loadCache();
  183. const expiryDays = this.settings.cacheExpiryDays;
  184. const expiryTime = Date.now() + (expiryDays * 24 * 60 * 60 * 1000);
  185.  
  186. cache[videoId] = { title: title, expiry: expiryTime };
  187. await this._saveCache();
  188. }
  189.  
  190. async clearExpired() {
  191. // Only run cleanup occasionally
  192. if (Math.random() >= DEFAULTS.CACHE_CLEANUP_PROBABILITY) return 0;
  193.  
  194. const cache = await this._loadCache();
  195. const now = Date.now();
  196. let changed = false;
  197. let malformedCount = 0;
  198. let expiredCount = 0;
  199.  
  200. for (const videoId in cache) {
  201. if (Object.hasOwnProperty.call(cache, videoId)) {
  202. const item = cache[videoId];
  203. // Check for invalid format or expiry
  204. if (!item || typeof item.title !== 'string' || typeof item.expiry !== 'number' || item.expiry <= now) {
  205. delete cache[videoId];
  206. changed = true;
  207. if (item && item.expiry <= now) expiredCount++;
  208. else malformedCount++;
  209. if (!item || typeof item.title !== 'string' || typeof item.expiry !== 'number') {
  210. Logger.warn(`Removed malformed cache entry: ${videoId}`);
  211. }
  212. }
  213. }
  214. }
  215.  
  216. if (changed) {
  217. await this._saveCache();
  218. const totalCleared = malformedCount + expiredCount;
  219. if (totalCleared > 0) {
  220. Logger.log(`Cleared ${totalCleared} cache entries (${expiredCount} expired, ${malformedCount} malformed).`);
  221. }
  222. }
  223. return expiredCount + malformedCount;
  224. }
  225.  
  226. async purgeAll() {
  227. try {
  228. this.cache = {}; // Clear in-memory cache
  229. await GM.setValue(CACHE_KEY_TITLES, {}); // Clear storage
  230. Logger.log('Title cache purged successfully.');
  231. return true;
  232. } catch (e) {
  233. Logger.error('Failed to purge title cache:', e);
  234. return false;
  235. }
  236. }
  237. }
  238.  
  239. // --- API Fetcher ---
  240. class ApiFetcher {
  241. async fetchVideoData(videoId) {
  242. const url = URL_TEMPLATES.OEMBED.replace('VIDEO_ID', videoId);
  243. return new Promise((resolve, reject) => {
  244. GM.xmlHttpRequest({
  245. method: "GET",
  246. url: url,
  247. responseType: "json",
  248. timeout: 10000,
  249. onload: (response) => {
  250. if (response.status === 200 && response.response?.title) {
  251. resolve(response.response);
  252. } else if ([401, 403, 404].includes(response.status)) {
  253. reject(new Error(`Video unavailable (Status: ${response.status})`));
  254. } else {
  255. reject(new Error(`oEmbed request failed (${response.statusText || `Status ${response.status}`})`));
  256. }
  257. },
  258. onerror: (err) => reject(new Error(`GM.xmlHttpRequest error: ${err.error || 'Network error'}`)),
  259. ontimeout: () => reject(new Error('oEmbed request timed out')),
  260. });
  261. });
  262. }
  263.  
  264. async fetchThumbnailAsDataURL(videoId) {
  265. const thumbnailUrl = URL_TEMPLATES.THUMBNAIL_WEBP.replace('VIDEO_ID', videoId);
  266. return new Promise((resolve) => {
  267. GM.xmlHttpRequest({
  268. method: "GET",
  269. url: thumbnailUrl,
  270. responseType: 'blob',
  271. timeout: 8000, // Slightly shorter timeout for images
  272. onload: (response) => {
  273. if (response.status === 200 && response.response) {
  274. const reader = new FileReader();
  275. reader.onloadend = () => resolve(reader.result); // result is the Data URL
  276. reader.onerror = (err) => {
  277. Logger.warn(`FileReader error for thumbnail ${videoId}:`, err);
  278. resolve(null); // Resolve with null on reader error
  279. };
  280. reader.readAsDataURL(response.response);
  281. } else {
  282. // Log non-200 status for debugging, but still resolve null
  283. if(response.status !== 404) Logger.warn(`Thumbnail fetch failed for ${videoId} (Status: ${response.status})`);
  284. resolve(null);
  285. }
  286. },
  287. onerror: (err) => {
  288. Logger.error(`GM.xmlHttpRequest error fetching thumbnail for ${videoId}:`, err);
  289. resolve(null);
  290. },
  291. ontimeout: () => {
  292. Logger.warn(`Timeout fetching thumbnail for ${videoId}`);
  293. resolve(null);
  294. }
  295. });
  296. });
  297. }
  298. }
  299.  
  300. // --- Link Enhancer (DOM Manipulation) ---
  301. class LinkEnhancer {
  302. constructor(titleCache, apiFetcher, settingsManager) {
  303. this.cache = titleCache;
  304. this.api = apiFetcher;
  305. this.settings = settingsManager;
  306. this.styleAdded = false;
  307. this.processingLinks = new Set(); // Track links currently being fetched
  308. }
  309.  
  310. addStyles() {
  311. if (this.styleAdded) return;
  312. const encodedSvg = `data:image/svg+xml;base64,${btoa(YOUTUBE_ICON_SVG)}`;
  313. const styles = `
  314. .youtubelink {
  315. position: relative;
  316. padding-left: 20px; /* Space for icon */
  317. display: inline-block; /* Prevent line breaks inside link */
  318. white-space: nowrap;
  319. text-decoration: none !important;
  320. /* Optional: slightly adjust vertical alignment if needed */
  321. /* vertical-align: middle; */
  322. }
  323. .youtubelink:hover {
  324. text-decoration: underline !important;
  325. }
  326. .youtubelink::before {
  327. content: '';
  328. position: absolute;
  329. left: 0px;
  330. top: 50%;
  331. transform: translateY(-50%);
  332. width: 16px; /* Icon size */
  333. height: 16px;
  334. background-image: url("${encodedSvg}");
  335. background-repeat: no-repeat;
  336. background-size: contain;
  337. background-position: center;
  338. /* vertical-align: middle; /* Align icon with text */
  339. }
  340. /* Thumbnail Popup Styles */
  341. #${DEFAULTS.THUMBNAIL_POPUP_ID} {
  342. position: fixed; display: none; z-index: 10000;
  343. border: 1px solid #555; background-color: #282828;
  344. padding: 2px; border-radius: 2px;
  345. box-shadow: 3px 3px 8px rgba(0,0,0,0.4);
  346. pointer-events: none; /* Don't interfere with mouse events */
  347. max-width: 320px; max-height: 180px; overflow: hidden;
  348. }
  349. #${DEFAULTS.THUMBNAIL_POPUP_ID} img {
  350. display: block; width: 100%; height: auto;
  351. max-height: 176px; /* Max height inside padding */
  352. object-fit: contain; background-color: #111;
  353. }
  354. /* Settings Panel Content (Scoped to parent div) */
  355. #${SCRIPT_ID}-panel-content > div { margin-bottom: 10px; }
  356. #${SCRIPT_ID}-panel-content input[type="number"] {
  357. width: 60px; padding: 3px; margin-left: 5px;
  358. border: 1px solid var(--settings-input-border, #ccc);
  359. background-color: var(--settings-input-bg, #fff);
  360. color: var(--settings-text, #000); box-sizing: border-box;
  361. }
  362. #${SCRIPT_ID}-panel-content input[type="checkbox"] { margin-right: 5px; vertical-align: middle; }
  363. #${SCRIPT_ID}-panel-content label.small { vertical-align: middle; font-size: 0.95em; }
  364. #${SCRIPT_ID}-panel-content button { margin-top: 5px; margin-right: 10px; padding: 4px 8px; }
  365. #${SCRIPT_ID}-save-status, #${SCRIPT_ID}-purge-status {
  366. margin-left: 10px; font-size: 0.9em;
  367. color: var(--settings-text, #ccc); font-style: italic;
  368. }
  369. `;
  370. GM_addStyle(styles);
  371. this.styleAdded = true;
  372. Logger.log('Styles added.');
  373. }
  374.  
  375. cleanLinkUrl(linkElement) {
  376. if (!linkElement?.href) return;
  377. const originalHref = linkElement.href;
  378. let cleanHref = originalHref;
  379.  
  380. // Normalize youtu.be, /live/, /shorts/ to standard watch?v= format
  381. if (cleanHref.includes('youtu.be/')) {
  382. const videoId = getVideoId(cleanHref);
  383. if (videoId) {
  384. const url = new URL(cleanHref);
  385. const timestamp = url.searchParams.get('t');
  386. cleanHref = `https://www.youtube.com/watch?v=${videoId}${timestamp ? `&t=${timestamp}` : ''}`;
  387. }
  388. } else {
  389. cleanHref = cleanHref.replace('/live/', '/watch?v=')
  390. .replace('/shorts/', '/watch?v=')
  391. .replace('/embed/', '/watch?v=')
  392. .replace('/v/', '/watch?v=');
  393. }
  394.  
  395. // Remove tracking parameters more reliably using URL API
  396. try {
  397. const url = new URL(cleanHref);
  398. const paramsToRemove = ['si', 'feature', 'ref', 'fsi', 'source', 'utm_source', 'utm_medium', 'utm_campaign', 'gclid', 'gclsrc', 'fbclid'];
  399. let changedParams = false;
  400. paramsToRemove.forEach(param => {
  401. if (url.searchParams.has(param)) {
  402. url.searchParams.delete(param);
  403. changedParams = true;
  404. }
  405. });
  406. if (changedParams) {
  407. cleanHref = url.toString();
  408. }
  409. } catch (e) {
  410. // Fallback to regex if URL parsing fails (e.g., malformed URL initially)
  411. cleanHref = cleanHref.replace(REGEX.YOUTUBE_TRACKING_PARAMS, '');
  412. cleanHref = cleanHref.replace(/(\?|&)$/, ''); // Remove trailing ? or &
  413. cleanHref = cleanHref.replace('?&', '?'); // Fix "?&" case
  414. }
  415.  
  416.  
  417. if (cleanHref !== originalHref) {
  418. try {
  419. linkElement.href = cleanHref;
  420. // Only update text if it exactly matched the old URL
  421. if (linkElement.textContent.trim() === originalHref.trim()) {
  422. linkElement.textContent = cleanHref;
  423. }
  424. } catch (e) {
  425. // This can happen if the element is removed from DOM during processing
  426. Logger.warn("Failed to update link href/text (element might be gone):", linkElement.textContent, e);
  427. }
  428. }
  429. }
  430.  
  431. findLinksInNode(node) {
  432. if (!node || node.nodeType !== Node.ELEMENT_NODE) return [];
  433.  
  434. const links = [];
  435. // Check if the node itself is a link in the target area
  436. if (node.matches && node.matches('.divMessage a')) {
  437. links.push(node);
  438. }
  439. // Find links within the node (or descendants) that are inside a .divMessage
  440. if (node.querySelectorAll) {
  441. const potentialLinks = node.querySelectorAll('.divMessage a');
  442. potentialLinks.forEach(link => {
  443. // Ensure the link is actually *within* a .divMessage that is a descendant of (or is) the input node
  444. if (node.contains(link) && link.closest('.divMessage')) {
  445. links.push(link);
  446. }
  447. });
  448. }
  449. // Return unique links only
  450. return [...new Set(links)];
  451. }
  452.  
  453.  
  454. async processLinks(links) {
  455. if (!links || links.length === 0) return;
  456.  
  457. // Perform opportunistic cache cleanup *before* heavy processing
  458. await this.cache.clearExpired();
  459.  
  460. const linksToFetch = [];
  461.  
  462. for (const link of links) {
  463. // Skip if already enhanced, marked as failed for a different reason, or currently being fetched
  464. // Note: We specifically allow reprocessing if ytFailed is 'no-id' from a previous incorrect run
  465. if (link.dataset.ytEnhanced ||
  466. (link.dataset.ytFailed && link.dataset.ytFailed !== 'no-id') ||
  467. this.processingLinks.has(link)) {
  468. continue;
  469. }
  470.  
  471. // --- Skip quotelinks ---
  472. if (link.classList.contains('quoteLink')) {
  473. // Mark as skipped so we don't check again
  474. link.dataset.ytFailed = 'skipped-type';
  475. continue; // Skip this link entirely, don't process further
  476. }
  477.  
  478. // --- PRIMARY FIX: Check for Video ID FIRST ---
  479. const videoId = getVideoId(link.href);
  480.  
  481. if (!videoId) {
  482. // It's NOT a YouTube link, or not one we can parse.
  483. // Mark as failed so we don't re-check it constantly.
  484. // Crucially, DO NOT call cleanLinkUrl or _applyTitle.
  485. link.dataset.ytFailed = 'no-id';
  486. // Optional: Remove old enhancement classes/data if they exist from a bad run
  487. // link.classList.remove("youtubelink");
  488. // delete link.dataset.videoId;
  489. continue; // Move to the next link in the list
  490. }
  491.  
  492. // --- If we reach here, it IS a potential YouTube link ---
  493.  
  494. // Now it's safe to clean the URL (only affects confirmed YT links)
  495. this.cleanLinkUrl(link);
  496.  
  497. // Add video ID attribute now that we know it's a YT link
  498. link.dataset.videoId = videoId;
  499. // Clear any previous 'no-id' failure flag if it existed
  500. delete link.dataset.ytFailed;
  501.  
  502. // Check cache for the title
  503. const cachedTitle = await this.cache.getTitle(videoId);
  504.  
  505. if (cachedTitle !== null) {
  506. // Title found in cache, apply it directly
  507. this._applyTitle(link, videoId, cachedTitle);
  508. } else {
  509. // Title not cached, mark for fetching
  510. this.processingLinks.add(link);
  511. linksToFetch.push({ link, videoId });
  512. }
  513. } // End of loop through links
  514.  
  515. // --- Process the batch of links needing API fetches ---
  516. if (linksToFetch.length === 0) {
  517. // Log only if there were links initially, but none needed fetching
  518. if (links.length > 0) Logger.log('No new links require title fetching.');
  519. return;
  520. }
  521.  
  522. Logger.log(`Fetching titles for ${linksToFetch.length} links...`);
  523.  
  524. // Fetch titles sequentially with delay
  525. for (let i = 0; i < linksToFetch.length; i++) {
  526. const { link, videoId } = linksToFetch[i];
  527.  
  528. // Double check if link still exists in DOM before fetching
  529. if (!document.body.contains(link)) {
  530. this.processingLinks.delete(link);
  531. Logger.warn(`Link removed from DOM before title fetch: ${videoId}`);
  532. continue;
  533. }
  534.  
  535. // Also check if it somehow got enhanced while waiting (e.g., duplicate link processed faster)
  536. if (link.dataset.ytEnhanced) {
  537. this.processingLinks.delete(link);
  538. continue;
  539. }
  540.  
  541. try {
  542. const videoData = await this.api.fetchVideoData(videoId);
  543. const title = videoData.title.trim() || '[Untitled Video]'; // Handle empty titles
  544. this._applyTitle(link, videoId, title);
  545. await this.cache.setTitle(videoId, title);
  546. } catch (e) {
  547. Logger.warn(`Failed to enhance link ${videoId}: ${e.message}`);
  548. // Apply error state visually AND cache it
  549. this._applyTitle(link, videoId, "[YT Fetch Error]"); // Show error to user
  550. await this.cache.setTitle(videoId, "[YT Fetch Error]"); // Cache error state
  551. link.dataset.ytFailed = 'fetch-error'; // Mark specific failure type
  552. } finally {
  553. this.processingLinks.delete(link); // Remove from processing set regardless of outcome
  554. }
  555.  
  556. // Apply delay between API calls
  557. if (i < linksToFetch.length - 1) {
  558. await delay(DEFAULTS.API_DELAY_MS);
  559. }
  560. }
  561. Logger.log(`Finished fetching batch.`);
  562. }
  563.  
  564. _applyTitle(link, videoId, title) {
  565. // Check if link still exists before modifying
  566. if (!document.body.contains(link)) {
  567. Logger.warn(`Link removed from DOM before applying title: ${videoId}`);
  568. return;
  569. }
  570. const displayTitle = (title === "[YT Fetch Error]") ? '[YT Error]' : title;
  571. // Use textContent for security, avoid potential HTML injection from titles
  572. link.textContent = `${displayTitle} [${videoId}]`;
  573. link.classList.add("youtubelink");
  574. link.dataset.ytEnhanced = "true"; // Mark as successfully enhanced
  575. delete link.dataset.ytFailed; // Remove failed flag if it was set previously
  576. }
  577.  
  578. // Force re-enhancement of all currently enhanced/failed links
  579. async reEnhanceAll() {
  580. Logger.log('Triggering re-enhancement of all detected YouTube links...');
  581. const links = document.querySelectorAll('a[data-video-id]');
  582. links.forEach(link => {
  583. delete link.dataset.ytEnhanced;
  584. delete link.dataset.ytFailed;
  585. // Reset text content only if it looks like our format, otherwise leave user-edited text
  586. if (link.classList.contains('youtubelink')) {
  587. const videoId = link.dataset.videoId;
  588. // Basic reset, might need refinement based on how cleanLinkUrl behaves
  589. link.textContent = link.href;
  590. this.cleanLinkUrl(link); // Re-clean the URL just in case
  591. }
  592. link.classList.remove('youtubelink');
  593. });
  594. await this.processLinks(Array.from(links)); // Process them again
  595. Logger.log('Re-enhancement process finished.');
  596. }
  597. }
  598.  
  599. // --- Thumbnail Preview ---
  600. class ThumbnailPreview {
  601. constructor(settingsManager, apiFetcher) {
  602. this.settings = settingsManager;
  603. this.api = apiFetcher;
  604. this.popupElement = null;
  605. this.imageElement = null;
  606. this.currentVideoId = null;
  607. this.isHovering = false;
  608. this.hideTimeout = null;
  609. this.fetchController = null; // AbortController for fetch
  610. }
  611.  
  612. createPopupElement() {
  613. if (document.getElementById(DEFAULTS.THUMBNAIL_POPUP_ID)) {
  614. this.popupElement = document.getElementById(DEFAULTS.THUMBNAIL_POPUP_ID);
  615. this.imageElement = this.popupElement.querySelector('img');
  616. if (!this.imageElement) { // Fix if img somehow got removed
  617. this.imageElement = document.createElement('img');
  618. this.imageElement.alt = "YouTube Thumbnail Preview";
  619. this.popupElement.appendChild(this.imageElement);
  620. }
  621. Logger.log('Re-using existing thumbnail popup element.');
  622. return;
  623. }
  624. this.popupElement = document.createElement('div');
  625. this.popupElement.id = DEFAULTS.THUMBNAIL_POPUP_ID;
  626.  
  627. this.imageElement = document.createElement('img');
  628. this.imageElement.alt = "YouTube Thumbnail Preview";
  629. this.imageElement.src = PLACEHOLDER_IMG_SRC;
  630. this.imageElement.onerror = () => {
  631. // Don't log error if we aborted the load or hid the popup
  632. if (this.isHovering && this.imageElement.src !== PLACEHOLDER_IMG_SRC) {
  633. Logger.warn(`Thumbnail image failed to load data for video ${this.currentVideoId || '(unknown)'}.`);
  634. }
  635. this.hide(); // Hide on error
  636. };
  637.  
  638. this.popupElement.appendChild(this.imageElement);
  639. document.body.appendChild(this.popupElement);
  640. Logger.log('Thumbnail popup created.');
  641. }
  642.  
  643. handleMouseOver(event) {
  644. if (!this.settings.showThumbnails || !this.popupElement) return;
  645.  
  646. const link = event.target.closest('.youtubelink[data-video-id]');
  647. if (!link) return;
  648.  
  649. const videoId = link.dataset.videoId;
  650. if (!videoId) return;
  651.  
  652. // Clear any pending hide action
  653. if (this.hideTimeout) {
  654. clearTimeout(this.hideTimeout);
  655. this.hideTimeout = null;
  656. }
  657.  
  658. this.isHovering = true;
  659.  
  660. // If it's a different video or the popup is hidden, show it
  661. if (videoId !== this.currentVideoId || this.popupElement.style.display === 'none') {
  662. this.currentVideoId = videoId;
  663. // Abort previous fetch if any
  664. this.fetchController?.abort();
  665. this.fetchController = new AbortController();
  666. this.show(event, videoId, this.fetchController.signal);
  667. }
  668. }
  669.  
  670. handleMouseOut(event) {
  671. if (!this.settings.showThumbnails || !this.isHovering) return;
  672.  
  673. const link = event.target.closest('.youtubelink[data-video-id]');
  674. if (!link) return; // Mouse out event not from a target link or its children
  675.  
  676. // Check if the mouse moved to the popup itself (though pointer-events: none should prevent this)
  677. // or to another element still within the original link
  678. if (event.relatedTarget && (link.contains(event.relatedTarget) || this.popupElement?.contains(event.relatedTarget))) {
  679. return;
  680. }
  681.  
  682. // Use a short delay before hiding
  683. this.hideTimeout = setTimeout(() => {
  684. this.isHovering = false;
  685. this.currentVideoId = null;
  686. this.fetchController?.abort(); // Abort fetch if mouse moves away quickly
  687. this.fetchController = null;
  688. this.hide();
  689. this.hideTimeout = null;
  690. }, DEFAULTS.THUMBNAIL_HIDE_DELAY_MS);
  691. }
  692.  
  693. async show(event, videoId, signal) {
  694. if (!this.isHovering || !this.popupElement || !this.imageElement) return;
  695.  
  696. // Reset image while loading
  697. this.imageElement.src = PLACEHOLDER_IMG_SRC;
  698. this.popupElement.style.display = 'block'; // Show popup frame immediately
  699. this.positionPopup(event); // Position based on initial event
  700.  
  701. try {
  702. const dataUrl = await this.api.fetchThumbnailAsDataURL(videoId);
  703.  
  704. // Check if fetch was aborted or if state changed during await
  705. if (signal?.aborted || !this.isHovering || videoId !== this.currentVideoId) {
  706. if (this.popupElement.style.display !== 'none') this.hide();
  707. return;
  708. }
  709.  
  710. if (dataUrl) {
  711. this.imageElement.src = dataUrl;
  712. // Reposition after image loads, as dimensions might change slightly
  713. // Use requestAnimationFrame for smoother updates if needed, but direct might be fine
  714. this.positionPopup(event);
  715. this.popupElement.style.display = 'block'; // Ensure it's visible
  716. } else {
  717. Logger.warn(`No thumbnail data URL received for ${videoId}. Hiding popup.`);
  718. this.hide();
  719. }
  720.  
  721. } catch (error) {
  722. if (error.name === 'AbortError') {
  723. Logger.log(`Thumbnail fetch aborted for ${videoId}.`);
  724. } else {
  725. Logger.error(`Error fetching thumbnail for ${videoId}:`, error);
  726. }
  727. this.hide(); // Hide on error
  728. }
  729. }
  730.  
  731. positionPopup(event) {
  732. if (!this.popupElement) return;
  733.  
  734. const offsetX = 15;
  735. const offsetY = 15;
  736. const buffer = 5; // Buffer from window edge
  737.  
  738. // Get potential dimensions (use max dimensions as fallback)
  739. const popupWidth = this.popupElement.offsetWidth || 320;
  740. const popupHeight = this.popupElement.offsetHeight || 180;
  741. const winWidth = window.innerWidth;
  742. const winHeight = window.innerHeight;
  743. const mouseX = event.clientX;
  744. const mouseY = event.clientY;
  745.  
  746. let x = mouseX + offsetX;
  747. let y = mouseY + offsetY;
  748.  
  749. // Adjust horizontal position
  750. if (x + popupWidth + buffer > winWidth) {
  751. x = mouseX - popupWidth - offsetX; // Flip to left
  752. }
  753. x = Math.max(buffer, x); // Ensure it's not off-screen left
  754.  
  755. // Adjust vertical position
  756. if (y + popupHeight + buffer > winHeight) {
  757. y = mouseY - popupHeight - offsetY; // Flip to top
  758. }
  759. y = Math.max(buffer, y); // Ensure it's not off-screen top
  760.  
  761. this.popupElement.style.left = `${x}px`;
  762. this.popupElement.style.top = `${y}px`;
  763. }
  764.  
  765.  
  766. hide() {
  767. if (this.popupElement) {
  768. this.popupElement.style.display = 'none';
  769. }
  770. if (this.imageElement) {
  771. this.imageElement.src = PLACEHOLDER_IMG_SRC; // Reset image
  772. }
  773. // Don't reset currentVideoId here, mouseover might happen again quickly
  774. }
  775.  
  776. attachListeners() {
  777. document.body.addEventListener('mouseover', this.handleMouseOver.bind(this));
  778. document.body.addEventListener('mouseout', this.handleMouseOut.bind(this));
  779. Logger.log('Thumbnail hover listeners attached.');
  780. }
  781. }
  782.  
  783. // --- Settings UI (STM Integration) ---
  784. class SettingsUI {
  785. constructor(settingsManager, titleCache, linkEnhancer) {
  786. this.settings = settingsManager;
  787. this.cache = titleCache;
  788. this.enhancer = linkEnhancer;
  789. this.stmRegistrationAttempted = false; // Prevent multiple attempts if somehow called again
  790. }
  791.  
  792. // Called by STM when the panel needs to be initialized
  793. initializePanel(panelElement) {
  794. Logger.log(`STM Initializing panel for ${SCRIPT_ID}`);
  795. // Use a specific ID for the content wrapper for easier targeting
  796. panelElement.innerHTML = `
  797. <div id="${SCRIPT_ID}-panel-content">
  798. <div>
  799. <strong>Title Cache:</strong><br>
  800. <label for="${SCRIPT_ID}-cache-expiry" class="small">Title Cache Expiry (Days):</label>
  801. <input type="number" id="${SCRIPT_ID}-cache-expiry" min="1" step="1" value="${this.settings.cacheExpiryDays}" title="Number of days to cache YouTube video titles">
  802. </div>
  803. <div>
  804. <button id="${SCRIPT_ID}-purge-cache">Purge Title Cache</button>
  805. <span id="${SCRIPT_ID}-purge-status"></span>
  806. </div>
  807. <hr style="border-color: #444; margin: 10px 0;">
  808. <div>
  809. <strong>Thumbnail Preview:</strong><br>
  810. <input type="checkbox" id="${SCRIPT_ID}-show-thumbnails" ${this.settings.showThumbnails ? 'checked' : ''}>
  811. <label for="${SCRIPT_ID}-show-thumbnails" class="small">Show Thumbnails on Hover</label>
  812. </div>
  813. <hr style="border-color: #444; margin: 15px 0 10px;">
  814. <div>
  815. <button id="${SCRIPT_ID}-save-settings">Save Settings</button>
  816. <span id="${SCRIPT_ID}-save-status"></span>
  817. </div>
  818. </div>`;
  819.  
  820. // Attach listeners using the specific IDs
  821. panelElement.querySelector(`#${SCRIPT_ID}-save-settings`)?.addEventListener('click', () => this.handleSaveClick(panelElement));
  822. panelElement.querySelector(`#${SCRIPT_ID}-purge-cache`)?.addEventListener('click', () => this.handlePurgeClick(panelElement));
  823. }
  824.  
  825. // Called by STM when the tab is activated
  826. activatePanel(panelElement) {
  827. Logger.log(`STM Activating panel for ${SCRIPT_ID}`);
  828. const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
  829. if (!contentWrapper) return;
  830.  
  831. // Update input values from current settings
  832. const expiryInput = contentWrapper.querySelector(`#${SCRIPT_ID}-cache-expiry`);
  833. const thumbCheckbox = contentWrapper.querySelector(`#${SCRIPT_ID}-show-thumbnails`);
  834. const saveStatusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-save-status`);
  835. const purgeStatusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-purge-status`);
  836.  
  837. if (expiryInput) expiryInput.value = this.settings.cacheExpiryDays;
  838. if (thumbCheckbox) thumbCheckbox.checked = this.settings.showThumbnails;
  839.  
  840. // Clear status messages on activation
  841. if (saveStatusSpan) saveStatusSpan.textContent = '';
  842. if (purgeStatusSpan) purgeStatusSpan.textContent = '';
  843. }
  844.  
  845. async handleSaveClick(panelElement) {
  846. const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
  847. if (!contentWrapper) { Logger.error("Cannot find panel content for saving."); return; }
  848.  
  849. const expiryInput = contentWrapper.querySelector(`#${SCRIPT_ID}-cache-expiry`);
  850. const thumbCheckbox = contentWrapper.querySelector(`#${SCRIPT_ID}-show-thumbnails`);
  851. const statusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-save-status`);
  852.  
  853. if (!expiryInput || !thumbCheckbox || !statusSpan) { Logger.error("Missing settings elements in panel."); return; }
  854.  
  855. const days = parseInt(expiryInput.value, 10);
  856.  
  857. if (isNaN(days) || days <= 0 || !Number.isInteger(days)) {
  858. this.showStatus(statusSpan, 'Invalid number of days!', 'red');
  859. Logger.warn('Attempted to save invalid cache expiry days:', expiryInput.value);
  860. return;
  861. }
  862.  
  863. // Update settings via the SettingsManager instance
  864. this.settings.cacheExpiryDays = days;
  865. this.settings.showThumbnails = thumbCheckbox.checked;
  866. await this.settings.save();
  867.  
  868. this.showStatus(statusSpan, 'Settings saved!', 'lime');
  869. Logger.log(`Settings saved via UI: Cache expiry ${days} days, Show Thumbnails ${thumbCheckbox.checked}.`);
  870.  
  871. // Apply thumbnail setting change immediately
  872. if (!this.settings.showThumbnails) {
  873. // Hide any currently visible thumbnail popup if setting is disabled
  874. const thumbnailPreview = window.ytle?.thumbnailPreview; // Access instance if exposed
  875. thumbnailPreview?.hide();
  876. }
  877. }
  878.  
  879. async handlePurgeClick(panelElement) {
  880. const contentWrapper = panelElement.querySelector(`#${SCRIPT_ID}-panel-content`);
  881. if (!contentWrapper) { Logger.error("Cannot find panel content for purging."); return; }
  882. const statusSpan = contentWrapper.querySelector(`#${SCRIPT_ID}-purge-status`);
  883. if (!statusSpan) { Logger.error("Missing purge status element."); return; }
  884.  
  885.  
  886. if (!confirm('Are you sure you want to purge the entire YouTube title cache?\nThis cannot be undone and will trigger re-fetching of all titles.')) {
  887. this.showStatus(statusSpan, 'Purge cancelled.', 'grey');
  888. return;
  889. }
  890.  
  891. this.showStatus(statusSpan, 'Purging cache...', 'orange');
  892. const success = await this.cache.purgeAll();
  893.  
  894. if (success) {
  895. this.showStatus(statusSpan, 'Cache purged! Re-enhancing links...', 'lime');
  896. // Trigger a re-enhancement of all known links
  897. await this.enhancer.reEnhanceAll();
  898. this.showStatus(statusSpan, 'Cache purged! Re-enhancement complete.', 'lime', 3000); // Update message after re-enhancement
  899. } else {
  900. this.showStatus(statusSpan, 'Purge failed!', 'red');
  901. }
  902. }
  903.  
  904. showStatus(spanElement, message, color, duration = 3000) {
  905. if (!spanElement) return;
  906. spanElement.textContent = message;
  907. spanElement.style.color = color;
  908. // Clear message after duration, only if the message hasn't changed
  909. setTimeout(() => {
  910. if (spanElement.textContent === message) {
  911. spanElement.textContent = '';
  912. spanElement.style.color = 'var(--settings-text, #ccc)'; // Reset color
  913. }
  914. }, duration);
  915. }
  916.  
  917. // --- Updated STM Registration with Timeout ---
  918. async registerWithSTM() {
  919. if (this.stmRegistrationAttempted) {
  920. Logger.log('STM registration already attempted, skipping.');
  921. return;
  922. }
  923. this.stmRegistrationAttempted = true;
  924.  
  925. let stmAttempts = 0;
  926. const MAX_STM_ATTEMPTS = 20; // 20 attempts
  927. const STM_RETRY_DELAY_MS = 250; // 250ms delay
  928. const MAX_WAIT_TIME_MS = MAX_STM_ATTEMPTS * STM_RETRY_DELAY_MS; // ~5 seconds total
  929.  
  930. const checkAndRegister = () => {
  931. stmAttempts++;
  932. // Use Logger.log for debugging attempts if needed
  933. // Logger.log(`STM check attempt ${stmAttempts}/${MAX_STM_ATTEMPTS}...`);
  934.  
  935. // *** Check unsafeWindow directly ***
  936. if (typeof unsafeWindow !== 'undefined'
  937. && typeof unsafeWindow.SettingsTabManager !== 'undefined'
  938. && typeof unsafeWindow.SettingsTabManager.ready !== 'undefined')
  939. {
  940. Logger.log('Found SettingsTabManager on unsafeWindow. Proceeding with registration...');
  941. // Found it, call the async registration function, but don't wait for it here.
  942. // Let the rest of the script initialization continue.
  943. performStmRegistration().catch(err => {
  944. Logger.error("Async registration with STM failed after finding it:", err);
  945. // Even if registration fails *after* finding STM, we proceed without the panel.
  946. });
  947. // STM found (or at least its .ready property), stop polling.
  948. return; // Exit the polling function
  949. }
  950.  
  951. // STM not found/ready yet, check if we should give up
  952. if (stmAttempts >= MAX_STM_ATTEMPTS) {
  953. Logger.warn(`SettingsTabManager not found or not ready after ${MAX_STM_ATTEMPTS} attempts (${(MAX_WAIT_TIME_MS / 1000).toFixed(1)} seconds). Proceeding without settings panel.`);
  954. // Give up polling, DO NOT call setTimeout again.
  955. return; // Exit the polling function
  956. }
  957.  
  958. // STM not found, limit not reached, schedule next attempt
  959. // Optional: Log if STM exists but .ready is missing
  960. // if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.SettingsTabManager !== 'undefined') {
  961. // Logger.log('Found SettingsTabManager on unsafeWindow, but .ready property is missing. Waiting...');
  962. // } else {
  963. // Logger.log('SettingsTabManager not found on unsafeWindow or not ready yet. Waiting...');
  964. // }
  965. setTimeout(checkAndRegister, STM_RETRY_DELAY_MS); // Retry after a delay
  966. };
  967.  
  968. const performStmRegistration = async () => {
  969. // This function now only runs if STM.ready was detected
  970. try {
  971. // *** Access via unsafeWindow ***
  972. // Ensure SettingsTabManager and .ready still exist before awaiting
  973. if (typeof unsafeWindow?.SettingsTabManager?.ready === 'undefined') {
  974. // Should not happen if called correctly, but check defensively
  975. Logger.error('SettingsTabManager.ready disappeared before registration could complete.');
  976. return; // Cannot register
  977. }
  978. const STM = await unsafeWindow.SettingsTabManager.ready;
  979. // *** End Access via unsafeWindow ***
  980.  
  981. Logger.log('SettingsTabManager ready, registering tab...');
  982. const registrationSuccess = STM.registerTab({
  983. scriptId: SCRIPT_ID,
  984. tabTitle: SCRIPT_NAME,
  985. order: 110, // Keep your desired order
  986. onInit: this.initializePanel.bind(this),
  987. onActivate: this.activatePanel.bind(this)
  988. });
  989.  
  990. if (registrationSuccess) {
  991. Logger.log(`Tab registration request sent successfully for ${SCRIPT_ID}.`);
  992. } else {
  993. Logger.warn(`STM registration for ${SCRIPT_ID} returned false (tab might already exist or other issue).`);
  994. }
  995.  
  996. } catch (err) {
  997. Logger.error('Failed during SettingsTabManager.ready await or registerTab call:', err);
  998. // No need to retry here, just log the failure.
  999. }
  1000. };
  1001.  
  1002. // Start the check/wait process *asynchronously*.
  1003. // This allows the main script initialization to continue immediately.
  1004. checkAndRegister();
  1005. }
  1006. }
  1007.  
  1008. // --- Main Application Class ---
  1009. class YouTubeLinkEnhancerApp {
  1010. constructor() {
  1011. this.settingsManager = new SettingsManager();
  1012. this.titleCache = new TitleCache(this.settingsManager);
  1013. this.apiFetcher = new ApiFetcher();
  1014. this.linkEnhancer = new LinkEnhancer(this.titleCache, this.apiFetcher, this.settingsManager);
  1015. this.thumbnailPreview = new ThumbnailPreview(this.settingsManager, this.apiFetcher);
  1016. this.settingsUI = new SettingsUI(this.settingsManager, this.titleCache, this.linkEnhancer);
  1017. this.observer = null;
  1018.  
  1019. // Expose instances for debugging/potential external interaction (optional)
  1020. // Be cautious with exposing internal state/methods
  1021. window.ytle = {
  1022. settings: this.settingsManager,
  1023. cache: this.titleCache,
  1024. enhancer: this.linkEnhancer,
  1025. thumbnailPreview: this.thumbnailPreview,
  1026. ui: this.settingsUI
  1027. };
  1028. }
  1029.  
  1030. async initialize() {
  1031. Logger.log('Initializing...');
  1032.  
  1033. // 1. Load settings
  1034. await this.settingsManager.load();
  1035.  
  1036. // 2. Add styles & create UI elements
  1037. this.linkEnhancer.addStyles();
  1038. this.thumbnailPreview.createPopupElement();
  1039.  
  1040. // 3. Attach global listeners
  1041. this.thumbnailPreview.attachListeners();
  1042.  
  1043. // 4. Register settings UI
  1044. await this.settingsUI.registerWithSTM();
  1045.  
  1046. // 5. Initial scan & process existing links
  1047. Logger.log('Running initial link processing...');
  1048. const initialLinks = this.linkEnhancer.findLinksInNode(document.body);
  1049. await this.linkEnhancer.processLinks(initialLinks);
  1050. Logger.log('Initial processing complete.');
  1051.  
  1052. // 6. Setup MutationObserver
  1053. this.setupObserver();
  1054.  
  1055. Logger.log('Initialization complete.');
  1056. }
  1057.  
  1058. setupObserver() {
  1059. this.observer = new MutationObserver(async (mutationsList) => {
  1060. let linksToProcess = new Set();
  1061.  
  1062. for (const mutation of mutationsList) {
  1063. if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
  1064. for (const addedNode of mutation.addedNodes) {
  1065. // Only process element nodes
  1066. if (addedNode.nodeType === Node.ELEMENT_NODE) {
  1067. const foundLinks = this.linkEnhancer.findLinksInNode(addedNode);
  1068. foundLinks.forEach(link => {
  1069. // Add link if it's potentially enhanceable (no videoId yet, or failed/not enhanced)
  1070. if (!link.dataset.videoId || !link.dataset.ytEnhanced || link.dataset.ytFailed) {
  1071. linksToProcess.add(link);
  1072. }
  1073. });
  1074. }
  1075. }
  1076. }
  1077. // Optional: Handle attribute changes if needed (e.g., href changes on existing links)
  1078. // else if (mutation.type === 'attributes' && mutation.attributeName === 'href') {
  1079. // const targetLink = mutation.target;
  1080. // if (targetLink.matches && targetLink.matches('.divMessage a') && targetLink.closest('.divMessage')) {
  1081. // // Handle potential re-enhancement if href changed
  1082. // delete targetLink.dataset.ytEnhanced;
  1083. // delete targetLink.dataset.ytFailed;
  1084. // delete targetLink.dataset.videoId;
  1085. // targetLink.classList.remove('youtubelink');
  1086. // linksToProcess.add(targetLink);
  1087. // }
  1088. //}
  1089. }
  1090.  
  1091. if (linksToProcess.size > 0) {
  1092. // Debounce slightly? Or process immediately? Immediate is simpler.
  1093. Logger.log(`Observer detected ${linksToProcess.size} new/updated potential links.`);
  1094. await this.linkEnhancer.processLinks([...linksToProcess]);
  1095. }
  1096. });
  1097.  
  1098. this.observer.observe(document.body, {
  1099. childList: true,
  1100. subtree: true,
  1101. // attributes: true, // Uncomment if you want to observe href changes
  1102. // attributeFilter: ['href'] // Only observe href attribute changes
  1103. });
  1104. Logger.log('MutationObserver started.');
  1105. }
  1106. }
  1107.  
  1108. // --- Script Entry Point ---
  1109. const app = new YouTubeLinkEnhancerApp();
  1110. app.initialize().catch(err => {
  1111. Logger.error("Initialization failed:", err);
  1112. });
  1113.  
  1114. })();