From b51092cb9001ff3b7d31a2e90aa9c6000cd0010d Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Wed, 22 Apr 2026 00:05:00 -0500 Subject: [PATCH 01/24] fix: add tools reference --- userscripts/mediux-autofill-description.user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/userscripts/mediux-autofill-description.user.js b/userscripts/mediux-autofill-description.user.js index 90ae485..6844259 100644 --- a/userscripts/mediux-autofill-description.user.js +++ b/userscripts/mediux-autofill-description.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Mediux - Auto-fill description field -// @version 1.0.0 +// @version 1.0.1 // @description Adds a button to auto-fill the description field with attribution text // @author Journey Over // @license MIT @@ -38,6 +38,7 @@
  • Databases & Repositories: CineMaterial, FanArt.tv, IMDB, MediUX, MovieStillsDB, PXFuel, Rotten Tomatoes, TMDB, TPDB, and TVDB.
  • Official Sources: Original networks, streaming services, and promotional materials.
  • General Search: Google Images.
  • +
  • Tools: Poster Tools (https://postertools.org/poster-lab/)
  • Original Content: Created and edited by me unless specifically stated otherwise.
  • If you feel that your artwork has been used improperly, please report the poster.

    From 5cb0367f66e10a1edebb7d8a712118fa6ea85010 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Thu, 23 Apr 2026 10:05:10 -0500 Subject: [PATCH 02/24] fix: Make it so that the members filter also filters out "members first" videos. --- userscripts/youtube-filters.user.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/userscripts/youtube-filters.user.js b/userscripts/youtube-filters.user.js index db7ab08..eac4b14 100644 --- a/userscripts/youtube-filters.user.js +++ b/userscripts/youtube-filters.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Filters -// @version 2.5.1 +// @version 2.5.2 // @description Filter out unwanted content on YouTube to enhance your browsing experience. (Currently is able to filter videos based on age and members-only status) // @author Journey Over // @license MIT @@ -74,12 +74,14 @@ const MEMBERS_SELECTORS = [ '.badge.badge-style-type-members-only', + '.badge.badge-style-type-members-first', 'badge-shape[aria-label*="Members only" i]', + 'badge-shape[aria-label*="Members first" i]', '.yt-badge-shape--commerce .yt-badge-shape__text', '.yt-badge-shape__text' ]; - const MEMBERS_REGEX = /\bmembers\s*[- ]?\s*only\b/i; + const MEMBERS_REGEX = /\bmembers\s*[- ]?\s*(only|first)\b/i; const UI = { overlayId: 'ytf-overlay', @@ -204,7 +206,13 @@ // ---------- Members-Only Filtering ---------- function isMembersOnlyBadge(badge) { - if (badge.classList.contains('badge-style-type-members-only')) return true; + if ( + badge.classList.contains('badge-style-type-members-only') || + badge.classList.contains('badge-style-type-members-first') + ) { + return true; + } + const label = badge.getAttribute('aria-label') || badge.textContent || ''; return MEMBERS_REGEX.test(label); } From 7553a8a982a2df764dab637aa644e724f99ff107 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Thu, 23 Apr 2026 17:27:32 -0500 Subject: [PATCH 03/24] refactor: Move some stuff around around and consolidate things. --- userscripts/youtube-filters.user.js | 235 ++++++++++++++++++---------- 1 file changed, 148 insertions(+), 87 deletions(-) diff --git a/userscripts/youtube-filters.user.js b/userscripts/youtube-filters.user.js index eac4b14..7e5618b 100644 --- a/userscripts/youtube-filters.user.js +++ b/userscripts/youtube-filters.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Filters -// @version 2.5.2 +// @version 2.5.3 // @description Filter out unwanted content on YouTube to enhance your browsing experience. (Currently is able to filter videos based on age and members-only status) // @author Journey Over // @license MIT @@ -82,6 +82,47 @@ ]; const MEMBERS_REGEX = /\bmembers\s*[- ]?\s*(only|first)\b/i; + const MEMBERS_SHELF_SUBTITLE_REGEX = /videos available to members/i; + const UNKNOWN_AGE_TEXT = 'Unknown'; + const CHANNEL_HANDLE_SEGMENT = '@'; + const RESCAN_DELAY_MS = 50; + const YOUTUBE_NAVIGATION_EVENTS = ['yt-navigate-finish', 'yt-page-data-updated']; + const UNIT_CONFIG = { + minutes: { factor: 525600, aliases: ['m', 'minute'] }, + hours: { factor: 8760, aliases: ['h', 'hour'] }, + days: { factor: 365, aliases: ['d', 'day'] }, + weeks: { factor: 52, aliases: ['w', 'week'] }, + months: { factor: 12, aliases: ['mo', 'month'] }, + years: { factor: 1, aliases: ['y', 'year'] } + }; + + const AGE_UNIT_ALIASES = Object.entries(UNIT_CONFIG).reduce((aliasMap, [unit, config]) => { + aliasMap[unit] = unit; + for (const alias of config.aliases) { + aliasMap[alias] = unit; + } + return aliasMap; + }, {}); + + const AGE_CONVERSIONS = Object.fromEntries( + Object.entries(UNIT_CONFIG).map(([unit, config]) => [unit, config.factor]) + ); + + const AGE_UNITS = Object.keys(UNIT_CONFIG); + + const AGE_TEXT_REGEX = new RegExp( + `(\\d+)\\s*(${Object.values(UNIT_CONFIG).flatMap(config => config.aliases).join('|')})s?\\s+ago`, + 'i' + ); + const VIDEO_SELECTOR_QUERY = VIDEO_SELECTORS.join(','); + const UNPROCESSED_VIDEO_SELECTOR_QUERY = VIDEO_SELECTORS.map(selector => `${selector}:not([data-processed])`).join(','); + const MEMBERS_SELECTOR_QUERY = MEMBERS_SELECTORS.join(','); + const SETTINGS_KEYS = { + debugEnabled: 'DEBUG_ENABLED', + ageThreshold: 'AGE_THRESHOLD', + membersOnlyEnabled: 'MEMBERS_ONLY_ENABLED', + ageFilteringEnabled: 'AGE_FILTERING_ENABLED' + }; const UI = { overlayId: 'ytf-overlay', @@ -92,12 +133,11 @@ const css = '#ytf-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);z-index:99999;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity 0.2s ease;font-family:"Roboto","Arial",sans-serif}#ytf-overlay.visible{opacity:1}#ytf-modal{background:#212121;color:#fff;width:400px;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);overflow:hidden;transform:scale(0.95);transition:transform 0.2s ease}#ytf-overlay.visible #ytf-modal{transform:scale(1)}.ytf-header{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.1);display:flex;justify-content:space-between;align-items:center;background:#181818}.ytf-title{font-size:18px;font-weight:500}.ytf-close{background:none;border:none;color:#aaa;font-size:24px;cursor:pointer;line-height:1;padding:0}.ytf-close:hover{color:#fff}.ytf-body{padding:10px 0;max-height:60vh;overflow-y:auto}.ytf-row{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,0.05);transition:background 0.2s}.ytf-row:last-child{border-bottom:none}.ytf-row:hover{background:rgba(255,255,255,0.03)}.ytf-label{font-size:14px;color:#eee}.ytf-switch{position:relative;display:inline-block;width:40px;height:24px}.ytf-switch input{opacity:0;width:0;height:0}.ytf-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#444;transition:.4s;border-radius:24px}.ytf-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background-color:white;transition:.4s;border-radius:50%}input:checked+.ytf-slider{background-color:#f00}input:checked+.ytf-slider:before{transform:translateX(16px)}.ytf-input-group{display:flex;gap:8px}.ytf-input,.ytf-select{background:#333;color:#fff;border:1px solid #555;padding:4px 8px;border-radius:4px;font-size:13px;outline:none}.ytf-input:focus,.ytf-select:focus{border-color:#f00}.ytf-input{width:60px}.ytf-footer{padding:16px 20px;border-top:1px solid rgba(255,255,255,0.1);display:flex;justify-content:flex-end;gap:12px;background:#181818}.ytf-btn{padding:8px 16px;border:none;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer;transition:background 0.2s;color:#fff}.ytf-btn-secondary{background:#444}.ytf-btn-secondary:hover{background:#555}.ytf-btn-primary{background:#f00}.ytf-btn-primary:hover{background:#d00}'; // ---------- Settings State ---------- - const DEBUG_ENABLED = GM_getValue('DEBUG_ENABLED', false); + const DEBUG_ENABLED = GM_getValue(SETTINGS_KEYS.debugEnabled, false); const logger = Logger('YT - Filters', { debug: DEBUG_ENABLED }); - const AGE_THRESHOLD = GM_getValue('AGE_THRESHOLD', { value: 4, unit: 'years' }); - const MEMBERS_ONLY_ENABLED = GM_getValue('MEMBERS_ONLY_ENABLED', false); - const AGE_FILTERING_ENABLED = GM_getValue('AGE_FILTERING_ENABLED', true); - const processedVideos = new WeakSet(); + const AGE_THRESHOLD = GM_getValue(SETTINGS_KEYS.ageThreshold, { value: 4, unit: 'years' }); + const MEMBERS_ONLY_ENABLED = GM_getValue(SETTINGS_KEYS.membersOnlyEnabled, false); + const AGE_FILTERING_ENABLED = GM_getValue(SETTINGS_KEYS.ageFilteringEnabled, true); // ---------- Utility Functions ---------- function injectStyle(styleText) { @@ -113,13 +153,34 @@ return element; } + /** + * Converts a relative age value into years. + * + * @param {number} value + * @param {string} unit + * @returns {number} + */ function convertToYears(value, unit) { - const conversions = { minutes: 525600, hours: 8760, days: 365, weeks: 52, months: 12, years: 1 }; - return value / (conversions[unit] || 1); + return value / (AGE_CONVERSIONS[unit] || 1); } - function matchesAnySelector(element, selectors) { - return selectors.some(selector => element.matches(selector)); + /** + * Parses a YouTube relative age label into normalized years. + * + * @param {string} ageText + * @returns {{ text: string, years: number } | null} + */ + function parseAgeText(ageText) { + if (!/\bago\b/i.test(ageText)) return null; + + const ageMatch = ageText.match(AGE_TEXT_REGEX); + if (!ageMatch) { + return { text: ageText, years: 0 }; + } + + const ageValue = parseInt(ageMatch[1], 10); + const ageUnit = AGE_UNIT_ALIASES[ageMatch[2].toLowerCase()] || 'years'; + return { text: ageText, years: convertToYears(ageValue, ageUnit) }; } function queryAll(root, selectors) { @@ -127,41 +188,21 @@ } // ---------- Video Processing ---------- + /** + * Returns the first recognized age label for a video. + * + * @param {Element} videoElement + * @returns {{ text: string, years: number }} + */ function getVideoAgeTextAndYears(videoElement) { for (const ageElement of queryAll(videoElement, AGE_SELECTORS)) { const ageText = (ageElement.textContent || '').trim(); - - if (/\bago\b/i.test(ageText)) { - // Matches both classic ("2 days ago", "1 month ago") and new abbreviated ("2d ago", "1mo ago") formats - const ageMatch = ageText.match(/(\d+)\s*(minute|hour|day|week|month|year|mo|m|h|d|w|y)s?\s+ago/i); - - if (ageMatch) { - const ageValue = parseInt(ageMatch[1], 10); - const rawUnit = ageMatch[2].toLowerCase(); - - const unitMapping = { - m: 'minutes', - minute: 'minutes', - h: 'hours', - hour: 'hours', - d: 'days', - day: 'days', - w: 'weeks', - week: 'weeks', - mo: 'months', - month: 'months', - y: 'years', - year: 'years' - }; - - const ageUnit = unitMapping[rawUnit] || 'years'; - const ageInYears = convertToYears(ageValue, ageUnit); - return { text: ageText, years: ageInYears }; - } - return { text: ageText, years: 0 }; + const parsedAge = parseAgeText(ageText); + if (parsedAge) { + return parsedAge; } } - return { text: 'Unknown', years: 0 }; + return { text: UNKNOWN_AGE_TEXT, years: 0 }; } function getVideoTitle(videoElement) { @@ -175,14 +216,12 @@ } function hideVideo(videoElement, reason) { - for (const selector of VIDEO_SELECTORS) { - const videoContainer = videoElement.closest(selector); - if (videoContainer) { - try { - videoContainer.setAttribute('hidden', 'true'); - } catch { - videoContainer.style.display = 'none'; - } + const videoContainer = videoElement.closest(VIDEO_SELECTOR_QUERY); + if (videoContainer) { + try { + videoContainer.setAttribute('hidden', 'true'); + } catch { + videoContainer.style.display = 'none'; } } logger.debug(`Hidden "${getVideoTitle(videoElement)}" (${reason})`); @@ -190,12 +229,9 @@ // ---------- Age Filtering ---------- function filterVideoByAge(videoElement) { - if (processedVideos.has(videoElement)) return; - const { text: ageText, years: ageYears } = getVideoAgeTextAndYears(videoElement); - if (ageText === 'Unknown') return; + if (ageText === UNKNOWN_AGE_TEXT) return; - processedVideos.add(videoElement); videoElement.dataset.processed = 'true'; const thresholdInYears = convertToYears(AGE_THRESHOLD.value, AGE_THRESHOLD.unit); @@ -205,6 +241,12 @@ } // ---------- Members-Only Filtering ---------- + /** + * Detects whether a badge marks Members-only or Members-first content. + * + * @param {Element} badge + * @returns {boolean} + */ function isMembersOnlyBadge(badge) { if ( badge.classList.contains('badge-style-type-members-only') || @@ -218,7 +260,7 @@ } function removeMembersOnlyVideo(badge) { - const videoElement = badge.closest(VIDEO_SELECTORS.join(',')); + const videoElement = badge.closest(VIDEO_SELECTOR_QUERY); if (videoElement) { videoElement.remove(); logger.debug(`Removed Members-only "${getVideoTitle(videoElement)}"`); @@ -229,7 +271,7 @@ for (const shelf of root.querySelectorAll('ytd-shelf-renderer')) { const title = (shelf.querySelector('#title')?.textContent || '').trim(); const subtitle = (shelf.querySelector('#subtitle')?.textContent || '').trim(); - if (MEMBERS_REGEX.test(title) || /videos available to members/i.test(subtitle)) { + if (MEMBERS_REGEX.test(title) || MEMBERS_SHELF_SUBTITLE_REGEX.test(subtitle)) { shelf.remove(); } } @@ -247,11 +289,10 @@ // ---------- Observers ---------- function processUnfilteredVideos() { try { - const unprocessedVideos = document.querySelectorAll( - VIDEO_SELECTORS.map(selector => `${selector}:not([data-processed])`).join(',') - ); + const unprocessedVideos = document.querySelectorAll(UNPROCESSED_VIDEO_SELECTOR_QUERY); + const shouldFilterAges = AGE_FILTERING_ENABLED && !window.location.href.includes(CHANNEL_HANDLE_SEGMENT); for (const videoElement of unprocessedVideos) { - if (AGE_FILTERING_ENABLED && !window.location.href.includes('@')) { + if (shouldFilterAges) { filterVideoByAge(videoElement); } } @@ -261,15 +302,25 @@ } } - function observeNewVideos() { - const unprocessedSelector = VIDEO_SELECTORS.map(selector => `${selector}:not([data-processed])`).join(','); + /** + * Re-runs a scan after YouTube client-side navigation events settle. + * + * @param {() => void} callback + */ + function registerYouTubeRescan(callback) { + const rescan = () => setTimeout(callback, RESCAN_DELAY_MS); + for (const eventName of YOUTUBE_NAVIGATION_EVENTS) { + window.addEventListener(eventName, rescan); + } + } + function observeNewVideos() { const observer = new MutationObserver(mutations => { for (const mutation of mutations) { if (mutation.type !== 'childList') continue; for (const node of mutation.addedNodes) { if (!(node instanceof Element)) continue; - if (node.matches(unprocessedSelector) || node.querySelector(unprocessedSelector)) { + if (node.matches(UNPROCESSED_VIDEO_SELECTOR_QUERY) || node.querySelector(UNPROCESSED_VIDEO_SELECTOR_QUERY)) { processUnfilteredVideos(); return; } @@ -278,22 +329,18 @@ }); observer.observe(document.documentElement, { childList: true, subtree: true }); - const rescan = () => setTimeout(processUnfilteredVideos, 50); - window.addEventListener('yt-navigate-finish', rescan); - window.addEventListener('yt-page-data-updated', rescan); + registerYouTubeRescan(processUnfilteredVideos); processUnfilteredVideos(); } function observeMembersOnly() { - // Use MutationObserver to detect newly added members-only badges and remove them - // Also listen to YouTube's custom events for page changes to rescan content const observer = new MutationObserver(mutations => { for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { if (!(node instanceof Element)) continue; - if (matchesAnySelector(node, MEMBERS_SELECTORS) && isMembersOnlyBadge(node)) { + if (node.matches(MEMBERS_SELECTOR_QUERY) && isMembersOnlyBadge(node)) { removeMembersOnlyVideo(node); } else { scanForMembersOnly(node); @@ -304,9 +351,7 @@ }); observer.observe(document.documentElement, { childList: true, subtree: true }); - const rescan = () => setTimeout(() => scanForMembersOnly(document), 50); - window.addEventListener('yt-navigate-finish', rescan); - window.addEventListener('yt-page-data-updated', rescan); + registerYouTubeRescan(() => scanForMembersOnly(document)); } // ---------- Settings UI ---------- @@ -354,7 +399,7 @@ const select = createElement('select', 'ytf-select'); select.id = 'ytf-threshold-unit'; select.setAttribute('aria-label', 'Age Threshold Unit'); - for (const unit of ['minutes', 'hours', 'days', 'weeks', 'months', 'years']) { + for (const unit of AGE_UNITS) { const opt = createElement('option'); opt.value = unit; opt.textContent = unit.charAt(0).toUpperCase() + unit.slice(1); @@ -376,13 +421,32 @@ return row; } + /** + * Persists settings to userscript storage. + * + * @param {{ + * ageFilteringEnabled: boolean, + * ageThreshold: { value: number, unit: string }, + * membersOnlyEnabled: boolean, + * debugEnabled: boolean + * }} settings + */ + function saveSettings(settings) { + GM_setValue(SETTINGS_KEYS.ageFilteringEnabled, settings.ageFilteringEnabled); + GM_setValue(SETTINGS_KEYS.ageThreshold, settings.ageThreshold); + GM_setValue(SETTINGS_KEYS.membersOnlyEnabled, settings.membersOnlyEnabled); + GM_setValue(SETTINGS_KEYS.debugEnabled, settings.debugEnabled); + } + function openSettingsMenu() { if (document.getElementById(UI.overlayId)) return; - let temporaryAgeFilteringEnabled = AGE_FILTERING_ENABLED; - let temporaryAgeThreshold = { ...AGE_THRESHOLD }; - let temporaryMembersOnlyEnabled = MEMBERS_ONLY_ENABLED; - let temporaryDebugEnabled = DEBUG_ENABLED; + const draftSettings = { + ageFilteringEnabled: AGE_FILTERING_ENABLED, + ageThreshold: { ...AGE_THRESHOLD }, + membersOnlyEnabled: MEMBERS_ONLY_ENABLED, + debugEnabled: DEBUG_ENABLED + }; const overlay = createElement('div'); overlay.id = UI.overlayId; @@ -408,20 +472,20 @@ const body = createElement('div', 'ytf-body'); - body.appendChild(createToggleRow('Enable Age Filtering', temporaryAgeFilteringEnabled, (checked) => { - temporaryAgeFilteringEnabled = checked; + body.appendChild(createToggleRow('Enable Age Filtering', draftSettings.ageFilteringEnabled, (checked) => { + draftSettings.ageFilteringEnabled = checked; })); - body.appendChild(createThresholdRow(temporaryAgeThreshold, (newThreshold) => { - temporaryAgeThreshold = newThreshold; + body.appendChild(createThresholdRow(draftSettings.ageThreshold, (newThreshold) => { + draftSettings.ageThreshold = newThreshold; })); - body.appendChild(createToggleRow('Hide Members-only Videos', temporaryMembersOnlyEnabled, (checked) => { - temporaryMembersOnlyEnabled = checked; + body.appendChild(createToggleRow('Hide Members-only Videos', draftSettings.membersOnlyEnabled, (checked) => { + draftSettings.membersOnlyEnabled = checked; })); - body.appendChild(createToggleRow('Debug Logging', temporaryDebugEnabled, (checked) => { - temporaryDebugEnabled = checked; + body.appendChild(createToggleRow('Debug Logging', draftSettings.debugEnabled, (checked) => { + draftSettings.debugEnabled = checked; })); const footer = createElement('div', 'ytf-footer'); @@ -431,10 +495,7 @@ const saveButton = createElement('button', 'ytf-btn ytf-btn-primary', 'Save & Reload'); saveButton.addEventListener('click', () => { - GM_setValue('AGE_FILTERING_ENABLED', temporaryAgeFilteringEnabled); - GM_setValue('AGE_THRESHOLD', temporaryAgeThreshold); - GM_setValue('MEMBERS_ONLY_ENABLED', temporaryMembersOnlyEnabled); - GM_setValue('DEBUG_ENABLED', temporaryDebugEnabled); + saveSettings(draftSettings); window.location.reload(); }); From e915d1a4d67e950edd9a2b35f6ef63bb660e5d9f Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sat, 25 Apr 2026 18:12:27 -0500 Subject: [PATCH 04/24] fix: allow RD button on duplicate magnet links --- .../magnet-link-to-real-debrid.user.js | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/userscripts/magnet-link-to-real-debrid.user.js b/userscripts/magnet-link-to-real-debrid.user.js index 8d06600..3293420 100644 --- a/userscripts/magnet-link-to-real-debrid.user.js +++ b/userscripts/magnet-link-to-real-debrid.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Magnet Link to Real-Debrid -// @version 2.12.0 +// @version 2.12.1 // @description Automatically send magnet links to Real-Debrid // @author Journey Over // @license MIT @@ -983,19 +983,25 @@ for (let index = 0; index < selectedUrls.length; index++) { const url = selectedUrls[index]; const key = this._magnetKeyFor(url); - const iconContainer = this.keyToIcon.get(key); - const icon = iconContainer ? (iconContainer.querySelector('.rd-icon') || iconContainer) : null; + const iconContainers = this._iconsForKey(key); + const icons = iconContainers.map(iconContainer => iconContainer.querySelector('.rd-icon') || iconContainer); UIManager.showToast(`Processing ${index + 1}/${selectedUrls.length} links...`, 'info'); - if (icon) UIManager.setIconState(icon, 'processing', config.enableTorrentSupport); + for (const icon of icons) { + UIManager.setIconState(icon, 'processing', config.enableTorrentSupport); + } try { await this.processor.processMagnetLink(url); successCount++; - if (icon) UIManager.setIconState(icon, 'added', config.enableTorrentSupport); + for (const icon of icons) { + UIManager.setIconState(icon, 'added', config.enableTorrentSupport); + } } catch (error) { errorCount++; - if (icon) UIManager.setIconState(icon, 'default', config.enableTorrentSupport); + for (const icon of icons) { + UIManager.setIconState(icon, 'default', config.enableTorrentSupport); + } logger.error(`[Batch Processing] Failed to process ${url}`, error); } } @@ -1015,6 +1021,21 @@ try { return `href:${href.trim().toLowerCase()}`; } catch { return `href:${String(href).trim().toLowerCase()}`; } } + _storeIconForKey(key, iconContainer) { + if (!key || !iconContainer) return; + const iconContainers = this.keyToIcon.get(key); + if (!iconContainers) { + this.keyToIcon.set(key, [iconContainer]); + return; + } + if (!iconContainers.includes(iconContainer)) iconContainers.push(iconContainer); + } + + _iconsForKey(key) { + if (!key) return []; + return this.keyToIcon.get(key) || []; + } + _attach(iconContainer, link) { const icon = iconContainer.querySelector('.rd-icon') || iconContainer; const checkbox = iconContainer.querySelector('input[type="checkbox"]'); @@ -1110,16 +1131,13 @@ if (link.hasAttribute('data-rd-processed')) { const key = this._magnetKeyFor(link.href); - if (key && !this.keyToIcon.has(key)) { - // Find the icon - it might not be the immediate next sibling anymore - const icon = link.parentNode.querySelector(`[${INSERTED_ICON_ATTR}]`); - if (icon) this.keyToIcon.set(key, icon); - } + // Find the icon - it might not be the immediate next sibling anymore + const icon = link.parentNode.querySelector(`[${INSERTED_ICON_ATTR}]`); + this._storeIconForKey(key, icon); continue; } const key = this._magnetKeyFor(link.href); - if (key && this.keyToIcon.has(key)) continue; const iconContainer = this._shouldShowBatchUI() ? UIManager.createMagnetIconWithCheckbox(torrentSupport) : @@ -1129,7 +1147,7 @@ link.parentNode.insertBefore(iconContainer, link.nextSibling); link.setAttribute('data-rd-processed', '1'); const storeKey = key || `href:${link.href.trim().toLowerCase()}`; - this.keyToIcon.set(storeKey, iconContainer); + this._storeIconForKey(storeKey, iconContainer); newlyAddedKeys.push(storeKey); } @@ -1145,12 +1163,14 @@ if (!this.processor) return; const config = ConfigManager.getConfigSync(); - for (const [key, iconContainer] of this.keyToIcon.entries()) { + for (const [key, iconContainers] of this.keyToIcon.entries()) { if (!key.startsWith('hash:')) continue; const hash = key.split(':')[1]; if (this.processor.isTorrentExists(hash)) { - const icon = iconContainer.querySelector('.rd-icon') || iconContainer; - UIManager.setIconState(icon, 'existing', config.enableTorrentSupport); + for (const iconContainer of iconContainers) { + const icon = iconContainer.querySelector('.rd-icon') || iconContainer; + UIManager.setIconState(icon, 'existing', config.enableTorrentSupport); + } } } } From c9d9c29f292ab207fd65124156f3791f8158e681 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sat, 25 Apr 2026 20:10:09 -0500 Subject: [PATCH 05/24] feat: Add option to hide live and premier videos --- userscripts/youtube-filters.user.js | 103 ++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/userscripts/youtube-filters.user.js b/userscripts/youtube-filters.user.js index 7e5618b..13498b5 100644 --- a/userscripts/youtube-filters.user.js +++ b/userscripts/youtube-filters.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Filters -// @version 2.5.3 +// @version 2.5.4 // @description Filter out unwanted content on YouTube to enhance your browsing experience. (Currently is able to filter videos based on age and members-only status) // @author Journey Over // @license MIT @@ -81,6 +81,15 @@ '.yt-badge-shape__text' ]; + const LIVE_PREMIERE_SELECTORS = [ + 'badge-shape.ytBadgeShapeThumbnailLive', + 'div.ytBadgeShapeText', + '.yt-badge-shape__text' + ]; + + const LIVE_BADGE_REGEX = /^\s*live\s*$/i; + const PREMIERE_BADGE_REGEX = /^\s*premiere\s*$/i; + const MEMBERS_REGEX = /\bmembers\s*[- ]?\s*(only|first)\b/i; const MEMBERS_SHELF_SUBTITLE_REGEX = /videos available to members/i; const UNKNOWN_AGE_TEXT = 'Unknown'; @@ -121,7 +130,9 @@ debugEnabled: 'DEBUG_ENABLED', ageThreshold: 'AGE_THRESHOLD', membersOnlyEnabled: 'MEMBERS_ONLY_ENABLED', - ageFilteringEnabled: 'AGE_FILTERING_ENABLED' + ageFilteringEnabled: 'AGE_FILTERING_ENABLED', + liveVideosEnabled: 'LIVE_VIDEOS_ENABLED', + premiereVideosEnabled: 'PREMIERE_VIDEOS_ENABLED' }; const UI = { @@ -138,6 +149,8 @@ const AGE_THRESHOLD = GM_getValue(SETTINGS_KEYS.ageThreshold, { value: 4, unit: 'years' }); const MEMBERS_ONLY_ENABLED = GM_getValue(SETTINGS_KEYS.membersOnlyEnabled, false); const AGE_FILTERING_ENABLED = GM_getValue(SETTINGS_KEYS.ageFilteringEnabled, true); + const LIVE_VIDEOS_ENABLED = GM_getValue(SETTINGS_KEYS.liveVideosEnabled, false); + const PREMIERE_VIDEOS_ENABLED = GM_getValue(SETTINGS_KEYS.premiereVideosEnabled, false); // ---------- Utility Functions ---------- function injectStyle(styleText) { @@ -216,14 +229,17 @@ } function hideVideo(videoElement, reason) { - const videoContainer = videoElement.closest(VIDEO_SELECTOR_QUERY); - if (videoContainer) { - try { - videoContainer.setAttribute('hidden', 'true'); - } catch { - videoContainer.style.display = 'none'; - } + let videoContainer = videoElement.closest(VIDEO_SELECTOR_QUERY); + if (!videoContainer) return; + + let parentContainer = videoContainer.parentElement?.closest(VIDEO_SELECTOR_QUERY); + while (parentContainer) { + videoContainer = parentContainer; + parentContainer = videoContainer.parentElement?.closest(VIDEO_SELECTOR_QUERY); } + + videoContainer.hidden = true; + videoContainer.style.setProperty('display', 'none', 'important'); logger.debug(`Hidden "${getVideoTitle(videoElement)}" (${reason})`); } @@ -232,14 +248,61 @@ const { text: ageText, years: ageYears } = getVideoAgeTextAndYears(videoElement); if (ageText === UNKNOWN_AGE_TEXT) return; - videoElement.dataset.processed = 'true'; - const thresholdInYears = convertToYears(AGE_THRESHOLD.value, AGE_THRESHOLD.unit); if (ageYears >= thresholdInYears) { hideVideo(videoElement, ageText); } } + /** + * Detects LIVE or PREMIERE badge on a video element. + * + * @param {Element} videoElement + * @returns {string} 'LIVE', 'PREMIERE', or '' + */ + function getVideoBroadcastBadge(videoElement) { + for (const badge of queryAll(videoElement, LIVE_PREMIERE_SELECTORS)) { + const label = (badge.getAttribute('aria-label') || badge.textContent || '').trim(); + if (LIVE_BADGE_REGEX.test(label)) return 'LIVE'; + if (PREMIERE_BADGE_REGEX.test(label)) return 'PREMIERE'; + } + return ''; + } + + /** + * Filters a video by its broadcast status (LIVE/PREMIERE). + * + * @param {Element} videoElement + * @returns {boolean} true if video was hidden + */ + function filterVideoByBroadcastStatus(videoElement) { + const badgeType = getVideoBroadcastBadge(videoElement); + + if (badgeType === 'LIVE' && LIVE_VIDEOS_ENABLED) { + hideVideo(videoElement, 'LIVE'); + return true; + } + + if (badgeType === 'PREMIERE' && PREMIERE_VIDEOS_ENABLED) { + hideVideo(videoElement, 'PREMIERE'); + return true; + } + + return false; + } + + /** + * Applies all video filters to an unprocessed video element. + * + * @param {Element} videoElement + * @param {boolean} shouldFilterAges + */ + function applyVideoFilters(videoElement, shouldFilterAges) { + videoElement.dataset.processed = 'true'; + if (filterVideoByBroadcastStatus(videoElement)) return; + if (shouldFilterAges) filterVideoByAge(videoElement); + } + // ---------- Members-Only Filtering ---------- /** * Detects whether a badge marks Members-only or Members-first content. @@ -292,9 +355,7 @@ const unprocessedVideos = document.querySelectorAll(UNPROCESSED_VIDEO_SELECTOR_QUERY); const shouldFilterAges = AGE_FILTERING_ENABLED && !window.location.href.includes(CHANNEL_HANDLE_SEGMENT); for (const videoElement of unprocessedVideos) { - if (shouldFilterAges) { - filterVideoByAge(videoElement); - } + applyVideoFilters(videoElement, shouldFilterAges); } if (MEMBERS_ONLY_ENABLED) pruneMembersShelf(); } catch (error) { @@ -428,6 +489,8 @@ * ageFilteringEnabled: boolean, * ageThreshold: { value: number, unit: string }, * membersOnlyEnabled: boolean, + * liveVideosEnabled: boolean, + * premiereVideosEnabled: boolean, * debugEnabled: boolean * }} settings */ @@ -435,6 +498,8 @@ GM_setValue(SETTINGS_KEYS.ageFilteringEnabled, settings.ageFilteringEnabled); GM_setValue(SETTINGS_KEYS.ageThreshold, settings.ageThreshold); GM_setValue(SETTINGS_KEYS.membersOnlyEnabled, settings.membersOnlyEnabled); + GM_setValue(SETTINGS_KEYS.liveVideosEnabled, settings.liveVideosEnabled); + GM_setValue(SETTINGS_KEYS.premiereVideosEnabled, settings.premiereVideosEnabled); GM_setValue(SETTINGS_KEYS.debugEnabled, settings.debugEnabled); } @@ -445,6 +510,8 @@ ageFilteringEnabled: AGE_FILTERING_ENABLED, ageThreshold: { ...AGE_THRESHOLD }, membersOnlyEnabled: MEMBERS_ONLY_ENABLED, + liveVideosEnabled: LIVE_VIDEOS_ENABLED, + premiereVideosEnabled: PREMIERE_VIDEOS_ENABLED, debugEnabled: DEBUG_ENABLED }; @@ -484,6 +551,14 @@ draftSettings.membersOnlyEnabled = checked; })); + body.appendChild(createToggleRow('Hide LIVE Videos', draftSettings.liveVideosEnabled, (checked) => { + draftSettings.liveVideosEnabled = checked; + })); + + body.appendChild(createToggleRow('Hide PREMIERE Videos', draftSettings.premiereVideosEnabled, (checked) => { + draftSettings.premiereVideosEnabled = checked; + })); + body.appendChild(createToggleRow('Debug Logging', draftSettings.debugEnabled, (checked) => { draftSettings.debugEnabled = checked; })); From 3d4f579bd47ad1dd4406c124cef76e472a90fa45 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sat, 25 Apr 2026 21:32:39 -0500 Subject: [PATCH 06/24] fix: fix live/premier hiding so it actually hides correctly. --- userscripts/youtube-filters.user.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/userscripts/youtube-filters.user.js b/userscripts/youtube-filters.user.js index 13498b5..cce5f01 100644 --- a/userscripts/youtube-filters.user.js +++ b/userscripts/youtube-filters.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Filters -// @version 2.5.4 +// @version 2.5.5 // @description Filter out unwanted content on YouTube to enhance your browsing experience. (Currently is able to filter videos based on age and members-only status) // @author Journey Over // @license MIT @@ -126,6 +126,7 @@ const VIDEO_SELECTOR_QUERY = VIDEO_SELECTORS.join(','); const UNPROCESSED_VIDEO_SELECTOR_QUERY = VIDEO_SELECTORS.map(selector => `${selector}:not([data-processed])`).join(','); const MEMBERS_SELECTOR_QUERY = MEMBERS_SELECTORS.join(','); + const LIVE_PREMIERE_SELECTOR_QUERY = LIVE_PREMIERE_SELECTORS.join(','); const SETTINGS_KEYS = { debugEnabled: 'DEBUG_ENABLED', ageThreshold: 'AGE_THRESHOLD', @@ -385,6 +386,16 @@ processUnfilteredVideos(); return; } + if (!LIVE_VIDEOS_ENABLED && !PREMIERE_VIDEOS_ENABLED) continue; + + const badgeElement = node.matches(LIVE_PREMIERE_SELECTOR_QUERY) ? + node : + node.querySelector(LIVE_PREMIERE_SELECTOR_QUERY); + const videoElement = badgeElement?.closest(VIDEO_SELECTOR_QUERY); + + if (videoElement) { + filterVideoByBroadcastStatus(videoElement); + } } } }); From a5d83bebd7563a677bceef32e2fd026a823b2667 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 26 Apr 2026 14:47:28 -0500 Subject: [PATCH 07/24] fix(magnet-link): always enable torrent support --- .../magnet-link-to-real-debrid.user.js | 62 +++++-------------- 1 file changed, 16 insertions(+), 46 deletions(-) diff --git a/userscripts/magnet-link-to-real-debrid.user.js b/userscripts/magnet-link-to-real-debrid.user.js index 3293420..981bf10 100644 --- a/userscripts/magnet-link-to-real-debrid.user.js +++ b/userscripts/magnet-link-to-real-debrid.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Magnet Link to Real-Debrid -// @version 2.12.1 +// @version 2.12.2 // @description Automatically send magnet links to Real-Debrid // @author Journey Over // @license MIT @@ -46,7 +46,6 @@ filterKeywords: ['sample', 'bloopers', 'trailer'], manualFileSelection: false, debugEnabled: false, - enableTorrentSupport: false }; class ConfigurationError extends Error { @@ -97,7 +96,6 @@ if (!Array.isArray(config.filterKeywords)) errors.push('filterKeywords must be an array'); if (typeof config.manualFileSelection !== 'boolean') errors.push('manualFileSelection must be a boolean'); if (typeof config.debugEnabled !== 'boolean') errors.push('debugEnabled must be a boolean'); - if (typeof config.enableTorrentSupport !== 'boolean') errors.push('enableTorrentSupport must be a boolean'); return errors; }, }; @@ -408,7 +406,7 @@ createConfigDialog(currentConfig) { this.injectStyles(); - const html = ``; + const html = ``; const overlay = document.createElement('div'); overlay.className = 'rd-overlay'; @@ -474,7 +472,6 @@ overlay.querySelector('.rd-close').addEventListener('click', close); overlay.querySelector('#cancelButton').addEventListener('click', close); - // Tab switching const tabs = overlay.querySelectorAll('.rd-nav-item'); for (const tab of tabs) { tab.addEventListener('click', () => { @@ -530,7 +527,6 @@ try { const newConfig = { apiKey: apiKeyValue, - enableTorrentSupport: overlay.querySelector('#enableTorrentSupport').checked, debugEnabled: overlay.querySelector('#debugEnabled').checked, manualFileSelection: manualCheckbox.checked, allowedExtensions: extensionsTextarea.value.split(',').map(extension => extension.trim()).filter(Boolean), @@ -579,7 +575,6 @@ }; updateStates(fileTree.root); - // Sync folder checkboxes in DOM const syncCheckboxes = (node, element) => { if (node.type === 'folder') { const checkbox = element.querySelector('.rd-checkbox'); @@ -678,9 +673,8 @@ updateUI(); toggleButton.onclick = () => { - const all = allFiles; - const value = all.some(file => !file.checked); - for (const file of all) file.checked = value; + const value = allFiles.some(file => !file.checked); + for (const file of allFiles) file.checked = value; updateUI(); treeRoot.innerHTML = ''; for (const child of fileTree.root.children) { @@ -894,10 +888,6 @@ }); }); } - - isTorrentSupportEnabled() { - return this.#config.enableTorrentSupport; - } } class PageIntegrator { @@ -906,7 +896,6 @@ this.observer = null; this.keyToIcon = new Map(); this.selectedLinks = new Set(); - this.totalMagnetLinks = 0; this.initialMagnetLinkCount = 0; this.batchButton = null; } @@ -975,8 +964,6 @@ UIManager.showToast('Real-Debrid API key not configured. Use the menu to set it.', 'info'); return; } - const config = ConfigManager.getConfigSync(); - let successCount = 0; let errorCount = 0; @@ -988,25 +975,24 @@ UIManager.showToast(`Processing ${index + 1}/${selectedUrls.length} links...`, 'info'); for (const icon of icons) { - UIManager.setIconState(icon, 'processing', config.enableTorrentSupport); + UIManager.setIconState(icon, 'processing', true); } try { await this.processor.processMagnetLink(url); successCount++; for (const icon of icons) { - UIManager.setIconState(icon, 'added', config.enableTorrentSupport); + UIManager.setIconState(icon, 'added', true); } } catch (error) { errorCount++; for (const icon of icons) { - UIManager.setIconState(icon, 'default', config.enableTorrentSupport); + UIManager.setIconState(icon, 'default', true); } logger.error(`[Batch Processing] Failed to process ${url}`, error); } } - // Clear selections after processing this.selectedLinks.clear(); this._updateBatchButton(); @@ -1041,19 +1027,11 @@ const checkbox = iconContainer.querySelector('input[type="checkbox"]'); const processLink = async (event) => { - if (icon.textContent === '✓') return; // Already processed - - // Fetch latest config for current operation - const config = ConfigManager.getConfigSync(); - const torrentSupport = config.enableTorrentSupport; + if (icon.textContent === '✓') return; const isMagnet = link.href.startsWith('magnet:'); let linkToProcess = link; if (isMagnet && event.altKey) { - if (!torrentSupport) { - UIManager.showToast('Torrent support not enabled. Enable it in settings.', 'info'); - return; - } const container = link.closest('tr') || link.closest('div') || link.closest('li') || link.parentElement; const torrentLink = container?.querySelector('a[href$=".torrent"]'); if (torrentLink) linkToProcess = torrentLink; @@ -1073,20 +1051,20 @@ if (isProcessingMagnet && key?.startsWith('hash:') && this.processor?.isTorrentExists(key.split(':')[1])) { UIManager.showToast('Torrent already exists on Real-Debrid', 'info'); - UIManager.setIconState(icon, 'existing', torrentSupport); // This sets text to checkmark + UIManager.setIconState(icon, 'existing', true); return; } - UIManager.setIconState(icon, 'processing', torrentSupport); + UIManager.setIconState(icon, 'processing', true); try { const fileCount = isProcessingMagnet ? await this.processor.processMagnetLink(linkToProcess.href) : await this.processor.processTorrentLink(linkToProcess.href); UIManager.showToast(`Added to Real-Debrid - ${fileCount} file(s) selected`, 'success'); - UIManager.setIconState(icon, 'added', torrentSupport); + UIManager.setIconState(icon, 'added', true); } catch (error) { - UIManager.setIconState(icon, 'default', torrentSupport); + UIManager.setIconState(icon, 'default', true); UIManager.showToast(error?.message || 'Failed to process link', 'error'); logger.error('[Link Processor] Failed to process link', error); } @@ -1100,7 +1078,7 @@ if (checkbox) { checkbox.addEventListener('change', (event_) => { event_.stopPropagation(); - if (icon.textContent === '✓') return; // Already processed + if (icon.textContent === '✓') return; if (checkbox.checked) this.selectedLinks.add(link.href); else this.selectedLinks.delete(link.href); this._updateBatchButton(); @@ -1111,7 +1089,6 @@ addIconsTo(documentRoot = document) { const links = [...documentRoot.querySelectorAll('a[href^="magnet:"]')]; - this.totalMagnetLinks = links.length; if (this.initialMagnetLinkCount === 0 && links.length > 0) { const uniqueHashes = new Set(); @@ -1121,17 +1098,12 @@ } this.initialMagnetLinkCount = uniqueHashes.size; } - - const config = ConfigManager.getConfigSync(); - const torrentSupport = config.enableTorrentSupport; - const newlyAddedKeys = []; for (const link of links) { if (!link.parentNode) continue; if (link.hasAttribute('data-rd-processed')) { const key = this._magnetKeyFor(link.href); - // Find the icon - it might not be the immediate next sibling anymore const icon = link.parentNode.querySelector(`[${INSERTED_ICON_ATTR}]`); this._storeIconForKey(key, icon); continue; @@ -1140,8 +1112,8 @@ const key = this._magnetKeyFor(link.href); const iconContainer = this._shouldShowBatchUI() ? - UIManager.createMagnetIconWithCheckbox(torrentSupport) : - UIManager.createMagnetIcon(torrentSupport); + UIManager.createMagnetIconWithCheckbox(true) : + UIManager.createMagnetIcon(true); this._attach(iconContainer, link); link.parentNode.insertBefore(iconContainer, link.nextSibling); @@ -1161,15 +1133,13 @@ markExistingTorrents() { if (!this.processor) return; - const config = ConfigManager.getConfigSync(); - for (const [key, iconContainers] of this.keyToIcon.entries()) { if (!key.startsWith('hash:')) continue; const hash = key.split(':')[1]; if (this.processor.isTorrentExists(hash)) { for (const iconContainer of iconContainers) { const icon = iconContainer.querySelector('.rd-icon') || iconContainer; - UIManager.setIconState(icon, 'existing', config.enableTorrentSupport); + UIManager.setIconState(icon, 'existing', true); } } } From 9e8f1b9bdc1acac2e76f3e8d2348f76817ec5bf4 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 26 Apr 2026 15:15:25 -0500 Subject: [PATCH 08/24] chore: Cleanup deadcode and several other things. --- README.md | 1 + libs/utils/utils.js | 14 +++++++------- libs/utils/utils.min.js | 2 +- package.json | 2 -- userscripts/github-latest.user.js | 10 +--------- userscripts/mediux-autofill-description.user.js | 6 +++--- userscripts/youtube-filters.user.js | 13 +++++++------ 7 files changed, 20 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2364d01..8b22bfd 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ This table shows supported browsers and their compatible userscript managers. Cl | Hy-Vee - Auto Clip Coupons | Add a button to manually clip all coupons on the Hy-Vee coupons page. | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/hyvee-auto-click-coupons.user.js) | | Magnet Link to Real-Debrid | Automatically send magnet links to Real-Debrid | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/magnet-link-to-real-debrid.user.js) | | Mediux - Yaml Fixes | Adds fixes and functions to Mediux | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-yaml-fixes.user.js) | +| Mediux - Auto-fill description | Auto-fills the description field on Mediux with year/type info | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-autofill-description.user.js) | | MyAnimeList - Add Trakt link | Add trakt link to MyAnimeList anime pages | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/myanimelist-add-trakt-link.user.js) | | AniList - Add Trakt link | Add trakt link to AniList anime pages | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/anilist-add-trakt-link.user.js) | | Nexus Mod - Updated Mod Highlighter | Highlight mods that have updated since you last downloaded them | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/nexusmods-updated-mod-highlighter.user.js) | diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 387e7ce..4c27b3e 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -5,30 +5,30 @@ // @name @journeyover/utils // @description Utility helpers for my userscripts // @license MIT -// @version 1.1.0 +// @version 1.1.1 // @homepageURL https://github.com/StylusThemes/Userscripts // ==/UserScript== /** - * Create a debounced function that delays calling `fn` until `wait` + * Create a debounced function that delays calling `callback` until `wait` * milliseconds have passed without another call. * * The returned function preserves the original `this` binding and forwards - * all arguments to `fn`. This implementation does not provide cancel/flush + * all arguments to `callback`. This implementation does not provide cancel/flush * helpers; it only postpones execution. * * Inputs: - * - fn: Function to invoke after the quiet period. + * - callback: Function to invoke after the quiet period. * - wait: Number of milliseconds to wait. * * Output: - * - A callable function that schedules `fn` and returns undefined. + * - A callable function that schedules `callback` and returns undefined. * * Edge cases: - * - If `fn` is not a function a TypeError will be thrown by the runtime when + * - If `callback` is not a function a TypeError will be thrown by the runtime when * attempting to call it. `wait` is coerced by the timer APIs to a number. * - * @param {Function} fn - Function to debounce. Called with the original `this`. + * @param {Function} callback - Function to debounce. Called with the original `this`. * @param {number} wait - Delay in milliseconds. * @returns {Function} A debounced wrapper function. * diff --git a/libs/utils/utils.min.js b/libs/utils/utils.min.js index 56889c5..dadbf94 100644 --- a/libs/utils/utils.min.js +++ b/libs/utils/utils.min.js @@ -5,7 +5,7 @@ // @name @journeyover/utils // @description Utility helpers for my userscripts // @license MIT -// @version 1.1.0 +// @version 1.1.1 // @homepageURL https://github.com/StylusThemes/Userscripts // ==/UserScript== diff --git a/package.json b/package.json index e53549f..9f48532 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,6 @@ "lint:fix": "eslint . --fix", "trash-regex": "bun ./scripts/trash-json-to-regex.js" }, - "dependencies": { - }, "devDependencies": { "js-beautify": "^1.15.4", "terser": "^5.44.1", diff --git a/userscripts/github-latest.user.js b/userscripts/github-latest.user.js index c2a35c7..b606b99 100644 --- a/userscripts/github-latest.user.js +++ b/userscripts/github-latest.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name GitHub - Latest -// @version 1.9.6 +// @version 1.9.7 // @description Always keep an eye on the latest activity of your favorite projects // @author Journey Over // @license MIT @@ -22,14 +22,6 @@ const QUERY_STRING = 'q=sort%3Aupdated-desc'; const NAVIGATION_SELECTOR = 'nav[aria-label="Repository"] > ul'; - const debounce = (callback, wait) => { - let timeout; - return (...callbackArguments) => { - clearTimeout(timeout); - timeout = setTimeout(() => callback.apply(this, callbackArguments), wait); - }; - }; - const findTemplateTab = (navigationBody) => { // Search for either the issues OR the pulls anchor const anchor = navigationBody.querySelector('a[href*="/issues"], a[href*="/pulls"]'); diff --git a/userscripts/mediux-autofill-description.user.js b/userscripts/mediux-autofill-description.user.js index 6844259..6286fa1 100644 --- a/userscripts/mediux-autofill-description.user.js +++ b/userscripts/mediux-autofill-description.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Mediux - Auto-fill description field -// @version 1.0.1 +// @version 1.0.2 // @description Adds a button to auto-fill the description field with attribution text // @author Journey Over // @license MIT @@ -9,8 +9,8 @@ // @grant none // @icon https://www.google.com/s2/favicons?sz=32&domain=mediux.pro // @homepageURL https://github.com/StylusThemes/Userscripts -// @downloadURL https://raw.githubusercontent.com/StylusThemes/Userscripts/master/userscripts/mediux-autofill-description.user.js -// @updateURL https://raw.githubusercontent.com/StylusThemes/Userscripts/master/userscripts/mediux-autofill-description.user.js +// @downloadURL https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-autofill-description.user.js +// @updateURL https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-autofill-description.user.js // ==/UserScript== (function() { diff --git a/userscripts/youtube-filters.user.js b/userscripts/youtube-filters.user.js index cce5f01..38f74a5 100644 --- a/userscripts/youtube-filters.user.js +++ b/userscripts/youtube-filters.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Filters -// @version 2.5.5 +// @version 2.5.6 // @description Filter out unwanted content on YouTube to enhance your browsing experience. (Currently is able to filter videos based on age and members-only status) // @author Journey Over // @license MIT @@ -125,6 +125,7 @@ ); const VIDEO_SELECTOR_QUERY = VIDEO_SELECTORS.join(','); const UNPROCESSED_VIDEO_SELECTOR_QUERY = VIDEO_SELECTORS.map(selector => `${selector}:not([data-processed])`).join(','); + const AGE_SELECTOR_QUERY = AGE_SELECTORS.join(','); const MEMBERS_SELECTOR_QUERY = MEMBERS_SELECTORS.join(','); const LIVE_PREMIERE_SELECTOR_QUERY = LIVE_PREMIERE_SELECTORS.join(','); const SETTINGS_KEYS = { @@ -197,8 +198,8 @@ return { text: ageText, years: convertToYears(ageValue, ageUnit) }; } - function queryAll(root, selectors) { - return root.querySelectorAll(selectors.join(',')); + function queryAll(root, selectorQuery) { + return root.querySelectorAll(selectorQuery); } // ---------- Video Processing ---------- @@ -209,7 +210,7 @@ * @returns {{ text: string, years: number }} */ function getVideoAgeTextAndYears(videoElement) { - for (const ageElement of queryAll(videoElement, AGE_SELECTORS)) { + for (const ageElement of queryAll(videoElement, AGE_SELECTOR_QUERY)) { const ageText = (ageElement.textContent || '').trim(); const parsedAge = parseAgeText(ageText); if (parsedAge) { @@ -262,7 +263,7 @@ * @returns {string} 'LIVE', 'PREMIERE', or '' */ function getVideoBroadcastBadge(videoElement) { - for (const badge of queryAll(videoElement, LIVE_PREMIERE_SELECTORS)) { + for (const badge of queryAll(videoElement, LIVE_PREMIERE_SELECTOR_QUERY)) { const label = (badge.getAttribute('aria-label') || badge.textContent || '').trim(); if (LIVE_BADGE_REGEX.test(label)) return 'LIVE'; if (PREMIERE_BADGE_REGEX.test(label)) return 'PREMIERE'; @@ -342,7 +343,7 @@ } function scanForMembersOnly(root = document) { - for (const badge of queryAll(root, MEMBERS_SELECTORS)) { + for (const badge of queryAll(root, MEMBERS_SELECTOR_QUERY)) { if (isMembersOnlyBadge(badge)) { removeMembersOnlyVideo(badge); } From 5ee944d2f5787ba51357fa77d0985d258f5eb02b Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 26 Apr 2026 17:22:43 -0500 Subject: [PATCH 09/24] Update agents.md --- AGENTS.md | 321 +++++++++++++++++++++++++++--------------------------- 1 file changed, 162 insertions(+), 159 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a29cc71..5aa8a36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,190 +1,193 @@ -# AGENTS.md – Guide for Autonomous Coding Agents +# AGENTS.md - Guide for Autonomous Coding Agents > Guidance for autonomous coding agents (for example: OpenAI Codex CLI, Copilot Agent Mode, Cursor, etc.) > Read this before writing, editing, or executing anything in this repository. > Execute every instruction as thoroughly and as accurately as possible. -> **Always perform full-file updates in one pass. Do not edit line-by-line.** --- -## 1. Repository Structure and Permissions +## 1. Operating Rules -Understand the repository layout and access rules to avoid unauthorized modifications. +- Follow this document before making any change. +- Prefer the smallest change that fully solves the problem. +- Preserve existing behavior unless the task explicitly requires a behavior change. +- When uncertain about scope, approval, or side effects, stop and open a PR instead of making a direct change. -### Directory and File Permissions +--- -| Path/File | Permission | Notes | -|--------------------|------------|-------| -| `libs/` | ✅ Allowed | Create or edit library code. | -| `libs/**` | ✅ Allowed | Modify subpackages and helper modules. | -| `scripts/` | ✅ Allowed | Edit build and utility scripts. | -| `userscripts/` | ✅ Allowed | Modify user script sources. | -| `package.json` | ⚠️ Careful | Update dependencies/scripts only if necessary; prefer PR with maintainer approval. | -| `bun.lock` | ❌ Forbidden | Do not edit lockfiles directly; use package manager. | -| `README.md` | ✅ Allowed | Update documentation. | -| `LICENSE` | ❌ Forbidden | Do not modify. | -| `AGENTS.md` | ❌ Forbidden | Do not modify. | +## 2. Quick Reference -**Key Guidelines:** -- For ⚠️ items, create a PR, describe changes, and seek maintainer sign-off. -- When uncertain, err on the side of caution—open a PR instead of direct commits. +### Command Reference ---- +| Task | Command | +| ---------------------------------- | ------------------- | +| Install dependencies | `bun install` | +| Add dependency | `bun add ` | +| Build / format / validate / minify | `bun run build` | +| Lint userscripts | `bun run lint` | -## 2. Development Environment Setup +### Required Before Commit -Set up your environment using Bun for dependency management and tooling. +| Check | Requirement | +| ---------------------- | ----------------------------- | +| Build | Must pass | +| Lint | Must pass | +| Manual userscript test | Required for affected targets | +| Commit style | Conventional Commits | -### Installation and Commands -```bash -bun install # Install all dependencies -bun add # Add new packages -bun run build # Format, validate, and minify JS in libs/ and userscripts/ -bun run lint # Lint userscripts for issues (e.g., unused variables) -``` +--- -- Always run `bun run build` and `bun run lint` before committing to ensure code quality. -- Use `bun` exclusively for package management—avoid npm/yarn. +## 3. Repository Permissions + +Use this table to decide what may be edited directly. + +| Path/File | Permission | Notes | +| ------------------------------------------------------- | ------------ | ----------------------------------------------------- | +| `libs/` | ✅ Allowed | Library source code. | +| `libs/**` | ✅ Allowed | Helpers, subpackages, shared utilities. | +| `scripts/` | ✅ Allowed | Build and utility scripts. | +| `scripts/**` | ✅ Allowed | Script internals and tooling code. | +| `userscripts/` | ✅ Allowed | Userscript source files. | +| `userscripts/**` | ✅ Allowed | Site-specific scripts and assets. | +| `README.md` | ✅ Allowed | Documentation updates are allowed. | +| `package.json` | ⚠️ Careful | Change only if necessary; prefer maintainer review. | +| `.github/**` | ⚠️ Careful | CI/workflow changes should be justified and reviewed. | +| config files (`*.json`, `*.mjs`, `*.cjs`, `*.config.*`) | ⚠️ Careful | Only edit when required by the task. | +| generated/minified outputs | ⚠️ Careful | Update only through the normal build flow. | +| `bun.lock` | ❌ Forbidden | Do not edit lockfiles directly. Use Bun. | +| `LICENSE` | ❌ Forbidden | Do not modify. | +| `AGENTS.md` | ❌ Forbidden | Do not modify. | +| secret files / credentials | ❌ Forbidden | Never commit secrets or tokens. | + +**Rules for ⚠️ paths** +- Make the minimum necessary change. +- Explain why the change is needed. +- Prefer a PR and maintainer sign-off for dependency or workflow changes. --- -## 3. Contribution Workflow +## 4. Workflow -Follow these practices for commits, pull requests, and collaboration. - -- **Commits**: Use Conventional Commits (e.g., `feat:`, `fix:`, `chore:`). Keep messages descriptive. -- **Pre-Commit Checks**: Run `bun run lint` and `bun run build` to validate changes. -- **Pull Requests**: - - Include a clear description of the purpose/issue. - - List key files changed and any follow-up actions. - - Await maintainer review for significant changes (e.g., dependencies). +1. Read the relevant files fully. +2. Confirm the target scope before changing anything. +3. Edit the relevant files. +4. Run: + ```bash + bun run lint + bun run build + ``` +5. Manually test affected userscripts in their target environment. +6. Prepare a Conventional Commit message. +7. Open a PR for anything significant, risky, or approval-sensitive. --- -## 4. Coding Standards and Best Practices - -Follow these rules exactly when writing or editing code in this repository. +## 5. Coding Standards -- **Formatting**: Use 2-space indentation, include trailing newlines, and target ES2021+ modules. -- **Imports**: Use Global / UMD style; do not use ES6 imports/exports. -- **Naming**: +- Use 2-space indentation. +- Include trailing newlines. +- Target ES2021+. +- Use Global / UMD style; **do not use ES6 imports/exports**. +- Use descriptive names: - camelCase for variables and functions - PascalCase for constructors - kebab-case for CSS class names - - Choose descriptive and meaningful names -- **Comments**: - - For **libs/**: - - All library code **must have JSDoc comments**. - - Inline `//` comments may be added for complex or non-obvious logic. - - For **userscripts/**: - - **Inline `//` comments should only be added for code that can't explain itself**. - - **Do not add comments that restate what the code already does**. - - **Do not add comments for variable or function names that are self-explanatory**. - - **JSDoc comments may be included if desired, but must never be required or enforced.** - - **Examples of Self-Explanatory vs Non-Self-Explanatory Code:** - ### Code That Can Explain Itself (No Comments Needed) - ```javascript - // ✅ GOOD: Clear variable names and structure - const userCartItems = getUserCart(currentUserId); - const cartTotal = calculateCartTotal(userCartItems); - - function validateUserInput(email, password) { - const isValidEmail = email.includes('@') && email.includes('.'); - const isValidPassword = password.length >= 8; - return isValidEmail && isValidPassword; - } - - const activeUsers = users.filter(user => user.isActive && user.lastLogin > oneWeekAgo); - - button.addEventListener('click', handleFormSubmission); - - let retryCount = 0; - const maxRetries = 3; - ``` - ### Code That Can't Explain Itself (Comments Required) - ```javascript - // ❌ BAD: Cryptic variable names and magic numbers - const x = getU(); - const y = getD(); - const r = p(x, y); - - // ✅ IMPROVED: With explanatory comments for non-obvious logic - // Complex regex to match magnet links only - avoids false positives on plain text hashes - const magnetRegex = /magnet:\?xt=urn:btih:[a-zA-Z0-9]{40}/; - - // Using innerHTML for performance on large content updates, despite XSS risk - content is fully controlled and sanitized - container.innerHTML = generateSafeHtml(data); - - // Delay execution to allow page scripts to initialize - prevents conflicts with site JS - setTimeout(() => modifyPage(), 1000); - - // Skip first table row - it's the header, not data - const rows = table.querySelectorAll('tr'); - rows.slice(1).forEach(processRow); - - // Bitwise permission check: 0b1000 represents admin access rights - const hasAdminAccess = userPermissions & 0b1000; - - // Temporary workaround for browser bug - remove when Chrome 95+ is minimum supported - if (navigator.userAgent.includes('Chrome/94')) { - applyChrome94Workaround(); - } - ``` - ### More Examples of Required Comments - ```javascript - // Non-obvious business logic that requires domain knowledge - // Company policy: users under 18 cannot purchase restricted items - const canPurchaseRestricted = userAge >= 18 && hasValidId; - - // Complex mathematical operations - // Convert degrees to radians for trigonometric functions - const angleInRadians = degrees * (Math.PI / 180); - - // Workarounds for specific browser quirks - // Firefox doesn't support the modern API, fall back to deprecated method - const storage = browser.storage || chrome.storage; - - // Performance optimization that sacrifices readability - // Precompute values to avoid redundant calculations in tight loop - const precomputedValues = expensiveArray.map(expensiveCalculation); - ``` - ### Examples of Unnecessary Comments - ```javascript - // ❌ BAD: Comments that state the obvious - let count = 0; // Initialize count to zero - - const element = document.getElementById('myElement'); // Get element by ID - - items.push(newItem); // Add new item to items array - - // Increment counter - count++; - - // Check if user is logged in - if (isLoggedIn) { - // Show user dashboard - showDashboard(); - } - ``` -- **Error Handling**: Handle errors gracefully in userscripts to prevent breaking the page. -- **Userscripts** (Specific Requirements): - - Use IIFE pattern: `(function() { 'use strict'; ... })();` (add `async` if needed) - - Include proper headers: `@name`, `@description`, `@version`, `@match`, `@grant`, etc. - - Test in target browsers using appropriate extensions - - Use modern web standards; avoid deprecated APIs - - Do not modify `@require` links without approval -- **Libraries**: Export utilities as named exports. -- **Styling**: - - Do not change the site's original functionality or appearance - - Always prefix selectors with a unique ID or class - - Minify CSS to reduce file size -- **Performance**: Write efficient code with lightweight DOM queries and event listeners +- Handle errors gracefully, especially in userscripts. +- Prefer simple, readable code over abstraction that does not clearly pay for itself. + +### Comments + +**For `libs/`:** +- JSDoc comments are required. +- Inline comments are allowed for non-obvious logic. + +**For `userscripts/`:** +- Inline comments should appear **only** when code cannot explain itself. +- Do not add comments that restate obvious code. +- JSDoc is optional, never required. --- -## 5. Validation and Deployment +## 6. Userscript Requirements + +Every userscript should follow these rules: + +- Use the IIFE pattern: + ```javascript + (function() { + 'use strict'; + })(); + ``` + Add `async` only if needed. + +- Include proper metadata headers such as: + - `@name` + - `@description` + - `@version` + - `@match` + - `@grant` + +- Use modern web standards. +- Avoid deprecated APIs. +- Do not modify `@require` links without approval. +- Do not change the site's original functionality or appearance unless the task explicitly requires it. +- Prefix selectors with a unique ID or class when injecting styles. +- Keep DOM queries and event listeners lightweight. + +--- + +## 7. Common Mistakes (Do Not Do This) + +- Do **not** use npm or yarn; use **Bun only**. +- Do **not** switch to ES module syntax in userscripts or library code that expects Global / UMD style. +- Do **not** add obvious comments. +- Do **not** change `@require` sources without approval. +- Do **not** broaden `@match` patterns carelessly. +- Do **not** introduce heavy DOM polling when an event, observer, or narrower hook will do. +- Do **not** break the host page if your script fails; fail safely. +- Do **not** skip manual browser testing for affected userscripts. + +--- + +## 8. Userscript Troubleshooting and Gotchas + +Check these first when a userscript "doesn't work": + +- **Wrong match pattern**: verify the page actually matches the metadata rules. +- **Execution timing issue**: site content may load after initial script execution. +- **Dynamic DOM**: target elements may be replaced after render; use resilient hooks. +- **Sandbox/API differences**: confirm required `@grant` values are present. +- **CSS collisions**: unprefixed selectors may affect unrelated page elements. +- **Page breakage from uncaught errors**: wrap risky logic so failures degrade safely. +- **Browser/extension differences**: test in the intended userscript manager and target browser. +- **Build output stale**: rerun build after modifying source files. + +--- -- **Testing**: Manually test userscripts in target environments to ensure functionality. -- **Build Process**: The `bun run build` command handles formatting, validation, and minification—run it post-changes. -- **Linting**: Address all ESLint warnings in userscripts before submission. -- Agents **must perform full-file edits in one motion**; do not make incremental line-by-line changes unless explicitly instructed. +## 9. Validation and Submission + +### Validation +- Run: + ```bash + bun run lint + bun run build + ``` +- Address all lint warnings in userscripts. +- Manually verify behavior in the target environment. + +### Commits +- Use Conventional Commits: + - `feat:` + - `fix:` + - `refactor:` + - `docs:` + - `chore:` + +### Pull Requests +Include: +- purpose of the change +- key files changed +- validation performed +- any follow-up work or approval needs + +If a change touches dependencies, workflows, config, or behavior with broad impact, prefer a PR with maintainer review. From aef81c0b9587890ba4f832d6741ed1b3395568e5 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Wed, 29 Apr 2026 23:00:18 -0500 Subject: [PATCH 10/24] fix: Sometimes the id ends up being null for whatever reason this fixes the formatting at least, doesn't end up fixing the null id though as that is a mediux problem not a script problem. --- userscripts/mediux-yaml-fixes.user.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userscripts/mediux-yaml-fixes.user.js b/userscripts/mediux-yaml-fixes.user.js index d13c101..28f4e19 100644 --- a/userscripts/mediux-yaml-fixes.user.js +++ b/userscripts/mediux-yaml-fixes.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Mediux - Yaml Fixes -// @version 2.2.3 +// @version 2.2.4 // @description Adds fixes and functions to Mediux // @author Journey Over // @license MIT @@ -333,7 +333,7 @@ const button = document.querySelector('#fytvbutton'); let yamlContent = codeblock.textContent; - const regexSetInfo = /(\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/\d+)/; + const regexSetInfo = /(null|\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/\d+)/; const year = MediuxFixes.utils.getYear(); From 7116ddfa791e8fb6a9e4af87abe8e1067d586e72 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Fri, 1 May 2026 15:39:42 -0500 Subject: [PATCH 11/24] watchlo dub information script this is only going to really be useful until they implement it themself some how. I just needed this so I can easily see the dub info on the site while I wait. --- userscripts/watchlo-dub-info.user.js | 767 +++++++++++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 userscripts/watchlo-dub-info.user.js diff --git a/userscripts/watchlo-dub-info.user.js b/userscripts/watchlo-dub-info.user.js new file mode 100644 index 0000000..79df106 --- /dev/null +++ b/userscripts/watchlo-dub-info.user.js @@ -0,0 +1,767 @@ +// ==UserScript== +// @name Watchlo Dub Info +// @version 0.1.4 +// @description Show dub availability for anime titles on Watchlo. +// @author Journey Over +// @license MIT +// @match https://watchlo.tv/* +// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/armhaglund/armhaglund.min.js +// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/anilist/anilist.min.js +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_xmlhttpRequest +// @grant GM_addStyle +// @run-at document-end +// @icon https://www.google.com/s2/favicons?sz=64&domain=watchlo.tv +// @homepageURL https://github.com/StylusThemes/Userscripts +// @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/watchlo-dub-info.user.js +// @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/watchlo-dub-info.user.js +// ==/UserScript== + +(function() { + 'use strict'; + + const CONFIG_KEY = 'watchlo-dub-config'; + const CACHE_PREFIX = 'watchlo-dub-cache'; + const CACHE_TTL = 24 * 60 * 60 * 1000; + const DEFAULT_CONFIG = { + enabled: true, + language: 'ENGLISH' + }; + + const LANGUAGE_OPTIONS = [ + { value: 'ENGLISH', label: 'English' }, + { value: 'JAPANESE', label: 'Japanese' }, + { value: 'PORTUGUESE', label: 'Portuguese (Brazil)' }, + { value: 'SPANISH', label: 'Spanish' }, + { value: 'FRENCH', label: 'French' }, + { value: 'GERMAN', label: 'German' }, + { value: 'ITALIAN', label: 'Italian' }, + { value: 'RUSSIAN', label: 'Russian' }, + { value: 'KOREAN', label: 'Korean' }, + { value: 'CHINESE', label: 'Chinese' } + ]; + + const LANGUAGE_LABELS = Object.fromEntries(LANGUAGE_OPTIONS.map(option => [option.value, option.label])); + + const anilist = new AniList(); + const armhaglund = new ArmHaglund(); + + function logError(...arguments_) { + globalThis.console.error('[Watchlo Dub Info]', ...arguments_); + } + + /** + * Create an HTML element with an optional class and text. + */ + function createElement(tagName, className, textContent) { + const element = document.createElement(tagName); + + if (className) { + element.className = className; + } + + if (textContent !== undefined) { + element.textContent = textContent; + } + + return element; + } + + /** + * Create an SVG element. + */ + function createSvgElement(tagName) { + return document.createElementNS('http://www.w3.org/2000/svg', tagName); + } + + /** + * Parse the current Watchlo URL into media metadata. + */ + function getMediaInfo() { + const match = location.pathname.match(/^\/(shows|movies)\/(\d+)-/); + + if (!match) { + return null; + } + + return { + mediaKind: match[1], + tmdbId: match[2] + }; + } + + function isRelevantPath() { + return location.pathname === '/settings' || getMediaInfo() !== null; + } + + /** + * Format a language code for display. + */ + function formatLanguageLabel(language) { + return LANGUAGE_LABELS[language] || language; + } + + function getLanguageOption(language) { + return LANGUAGE_OPTIONS.find(option => option.value === language) || LANGUAGE_OPTIONS[0]; + } + + function bindLanguageDropdownEvents(instance) { + if (instance.languageDropdownEventsBound) { + return; + } + + document.addEventListener('click', event => { + const row = instance.languageDropdownRow; + if (!row || !(event.target instanceof Node) || row.contains(event.target)) { + return; + } + + instance.closeLanguageDropdown(row); + }); + + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && instance.languageDropdownRow) { + instance.closeLanguageDropdown(instance.languageDropdownRow); + } + }); + + instance.languageDropdownEventsBound = true; + } + + class WatchloDubInfo { + constructor() { + this.config = { ...DEFAULT_CONFIG }; + this.observer = null; + this.routeInterval = null; + this.syncTimer = null; + this.languageDropdownRow = null; + this.languageDropdownEventsBound = false; + this.pendingMediaKey = null; + this.activeMediaKey = null; + this.lastRoute = location.href; + this.lastHistoryState = history.state; + this.init(); + } + + /** + * Initialize config, observer, and first render pass. + */ + init() { + try { + this.loadConfig(); + this.startRouteWatcher(); + this.startDomObserver(); + if (isRelevantPath()) { + void this.handlePage(); + } + } catch (error) { + logError('Initialization failed', error); + } + } + + loadConfig() { + try { + const savedConfig = GM_getValue(CONFIG_KEY); + if (savedConfig && typeof savedConfig === 'object') { + this.config = { ...DEFAULT_CONFIG, ...savedConfig }; + } + } catch (error) { + logError('Failed to load config', error); + } + } + + saveConfig() { + try { + GM_setValue(CONFIG_KEY, { ...this.config }); + } catch (error) { + logError('Failed to save config', error); + } + } + + startRouteWatcher() { + const checkRoute = () => { + const currentHref = location.href; + const currentState = history.state; + + if (currentHref === this.lastRoute && currentState === this.lastHistoryState) { + return; + } + + this.lastRoute = currentHref; + this.lastHistoryState = currentState; + + if (isRelevantPath()) { + void this.handlePage(); + return; + } + + this.resetDetailState(); + this.removeDubInfo(); + }; + + const wrapHistoryMethod = methodName => { + const original = history[methodName]; + if (typeof original !== 'function' || original.__watchloDubWrapped) { + return; + } + + const wrapped = function() { + const result = original.apply(this, arguments); + window.dispatchEvent(new Event('watchlo-dub-routechange')); + return result; + }; + + wrapped.__watchloDubWrapped = true; + history[methodName] = wrapped; + }; + + wrapHistoryMethod('pushState'); + wrapHistoryMethod('replaceState'); + + window.addEventListener('popstate', checkRoute); + window.addEventListener('watchlo-dub-routechange', checkRoute); + + this.routeInterval = window.setInterval(checkRoute, 250); + } + + startDomObserver() { + const attachObserver = () => { + if (!document.body || this.observer) { + return; + } + + this.observer = new MutationObserver(() => this.scheduleSync()); + this.observer.observe(document.body, { + childList: true, + subtree: true + }); + }; + + if (document.body) { + attachObserver(); + return; + } + + document.addEventListener('DOMContentLoaded', attachObserver, { once: true }); + } + + scheduleSync() { + if (this.syncTimer !== null) { + return; + } + + this.syncTimer = window.setTimeout(() => { + this.syncTimer = null; + + if (isRelevantPath()) { + void this.handlePage(); + } + }, 50); + } + + /** + * Route the current page to the matching feature. + */ + async handlePage() { + try { + if (location.pathname === '/settings') { + this.syncSettingsPage(); + return; + } + + const mediaInfo = getMediaInfo(); + if (!mediaInfo) { + this.resetDetailState(); + this.removeDubInfo(); + return; + } + + await this.syncDetailPage(mediaInfo); + } catch (error) { + logError('Page handling failed', error); + } + } + + resetDetailState() { + this.pendingMediaKey = null; + this.activeMediaKey = null; + } + + removeDubInfo() { + const node = document.querySelector('[data-watchlo-dub-info="true"]'); + if (node) { + node.remove(); + } + } + + getMediaKey(mediaInfo) { + return `${mediaInfo.mediaKind}:${mediaInfo.tmdbId}`; + } + + getCacheKey(mediaInfo) { + return `${CACHE_PREFIX}:${mediaInfo.mediaKind}:${mediaInfo.tmdbId}`; + } + + isCacheValid(cache) { + return !!cache?.checkedAt && Date.now() - cache.checkedAt < CACHE_TTL; + } + + getCachedResult(mediaInfo) { + try { + const cache = GM_getValue(this.getCacheKey(mediaInfo)); + + if (!this.isCacheValid(cache) || cache?.anilistId == null) { + return null; + } + + const cachedLanguageResult = cache.dubByLanguage?.[this.config.language]; + return cachedLanguageResult === undefined ? null : cachedLanguageResult; + } catch (error) { + logError('Failed reading cache', error); + return null; + } + } + + cacheConfirmedResult(mediaInfo, anilistId, language, hasDub) { + try { + const cacheKey = this.getCacheKey(mediaInfo); + const currentCache = GM_getValue(cacheKey) || {}; + + GM_setValue(cacheKey, { + checkedAt: Date.now(), + anilistId, + dubByLanguage: { + ...(currentCache.dubByLanguage || {}), + [language]: hasDub + } + }); + } catch (error) { + logError('Failed writing cache', error); + } + } + + /** + * Resolve AniList ID and render the dub marker when available. + */ + async syncDetailPage(mediaInfo) { + const mediaKey = this.getMediaKey(mediaInfo); + const existingNode = document.querySelector('[data-watchlo-dub-info="true"]'); + + if (!this.config.enabled) { + this.removeDubInfo(); + this.resetDetailState(); + return; + } + + if (this.activeMediaKey && this.activeMediaKey !== mediaKey) { + this.removeDubInfo(); + this.resetDetailState(); + } + + if (existingNode) { + this.activeMediaKey = mediaKey; + return; + } + + const cachedResult = this.getCachedResult(mediaInfo); + if (cachedResult !== null) { + this.activeMediaKey = mediaKey; + + if (cachedResult) { + this.insertDubInfo(mediaInfo); + } + + return; + } + + if (this.pendingMediaKey === mediaKey || this.activeMediaKey === mediaKey) { + return; + } + + this.pendingMediaKey = mediaKey; + + try { + const anilistId = await this.resolveAniListId(mediaInfo); + if (!anilistId) { + logError('AniList ID not resolved', mediaInfo); + return; + } + + const hasDub = await this.queryAniListDub(anilistId, this.config.language); + if (this.getMediaKey(getMediaInfo() || mediaInfo) !== mediaKey) { + return; + } + + this.cacheConfirmedResult(mediaInfo, anilistId, this.config.language, hasDub); + + if (hasDub) { + this.insertDubInfo(mediaInfo); + } + + this.activeMediaKey = mediaKey; + } catch (error) { + logError('Detail page processing failed', error); + this.activeMediaKey = mediaKey; + } finally { + if (this.pendingMediaKey === mediaKey) { + this.pendingMediaKey = null; + } + } + } + + async resolveAniListId() { + try { + const anilistLink = document.querySelector('a[href*="anilist.co/anime/"]'); + const href = anilistLink?.getAttribute('href') || ''; + const match = href.match(/\/anime\/(\d+)(?:\/|$)/); + let anilistId = match ? match[1] : null; + + const tmdbLink = document.querySelector('a[href*="themoviedb.org/tv/"], a[href*="themoviedb.org/movie/"]'); + const tmdbId = tmdbLink?.href.match(/\/(?:tv|movie)\/(\d+)(?:\/)?$/)?.[1] || null; + + if (!anilistId && tmdbId) { + try { + const ids = await armhaglund.fetchIds('themoviedb', tmdbId); + anilistId = ids?.anilist ? String(ids.anilist) : null; + } catch (error) { + logError('ArmHaglund fallback failed', error.message); + } + } + + return anilistId; + } catch (error) { + logError('Failed to resolve AniList ID', error); + return null; + } + } + + /** + * Query AniList for a dub match. + */ + async queryAniListDub(anilistId, language) { + const query = ` + query($id: Int!, $type: MediaType, $page: Int = 1, $language: StaffLanguage) { + Media(id: $id, type: $type) { + characters(page: $page, sort: [ROLE], role: MAIN) { + edges { + node { id } + voiceActors(language: $language) { + language + } + } + } + } + } + `; + + const allResults = []; + + for (let page = 1; page <= 3; page++) { + try { + const response = await anilist.query(query, { + id: Number(anilistId), + type: 'ANIME', + page, + language + }); + + const edges = response?.data?.Media?.characters?.edges || []; + allResults.push(...edges); + + if (edges.length === 0) { + break; + } + } catch { + break; + } + } + + return allResults.some(edge => (edge?.voiceActors?.length || 0) > 0); + } + + /** + * Insert the dub badge after the Japan/Japanese metadata row. + */ + insertDubInfo() { + if (document.querySelector('[data-watchlo-dub-info="true"]')) { + return; + } + + const anchor = this.findMetadataAnchor(); + if (!anchor) { + return; + } + + const label = formatLanguageLabel(this.config.language); + const dubNode = this.createDubNode(label); + anchor.after(dubNode); + } + + findMetadataAnchor() { + const candidates = [...document.querySelectorAll('.flex.items-center')]; + return candidates.find(candidate => { + const text = (candidate.textContent || '').trim(); + return /^Japanese$/i.test(text) || /\bJapanese\b/i.test(text); + }) || null; + } + + createDubNode(languageLabel) { + const node = createElement('div', 'flex items-center gap-1.5'); + node.dataset.watchloDubInfo = 'true'; + + const svg = createSvgElement('svg'); + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + svg.setAttribute('width', '24'); + svg.setAttribute('height', '24'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + svg.classList.add('h-3.5', 'w-3.5', 'text-muted-foreground/80'); + + const path = createSvgElement('path'); + path.setAttribute('d', 'M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3'); + + const text = createElement('span', 'text-[13px] text-muted-foreground/80', `${languageLabel} Dub Exists`); + + svg.appendChild(path); + node.appendChild(svg); + node.appendChild(text); + + return node; + } + + /** + * Inject or refresh the settings toggle row. + */ + syncSettingsPage() { + const section = this.findPreferencesSection(); + if (!section) { + return; + } + + let row = section.querySelector('[data-watchlo-dub-setting="true"]'); + if (!row) { + row = this.createSettingRow(); + } + + const anchor = this.findAnimeDisplayModeRow(section); + if (anchor) { + anchor.after(row); + } else { + const list = section.querySelector('ul, ol, [role="list"]'); + if (list) { + list.appendChild(row); + } else { + section.appendChild(row); + } + } + + this.updateSettingRow(row); + } + + findPreferencesSection() { + const sections = [...document.querySelectorAll('section')]; + + for (const section of sections) { + const heading = section.querySelector('h1, h2, h3, h4, h5, h6'); + if (heading && /Preferences/i.test(heading.textContent || '')) { + return section; + } + } + + return null; + } + + findAnimeDisplayModeRow(section) { + const candidates = [...section.querySelectorAll('li, [role="listitem"], label, p, span, div')]; + + for (const candidate of candidates) { + const text = (candidate.textContent || '').replace(/\s+/g, ' ').trim(); + if (!/Anime Display Mode/i.test(text)) { + continue; + } + + const row = candidate.closest('li, [role="listitem"]'); + if (row && row !== section) { + return row; + } + } + + return null; + } + + openLanguageDropdown(row) { + const panel = row.querySelector('[data-dub-language-panel="true"]'); + const button = row.querySelector('[data-dub-language-button="true"]'); + + if (!panel || !button) { + return; + } + + this.languageDropdownRow = row; + panel.hidden = false; + button.setAttribute('aria-expanded', 'true'); + } + + closeLanguageDropdown(row) { + const panel = row?.querySelector('[data-dub-language-panel="true"]'); + const button = row?.querySelector('[data-dub-language-button="true"]'); + + if (panel) { + panel.hidden = true; + } + + if (button) { + button.setAttribute('aria-expanded', 'false'); + } + + if (this.languageDropdownRow === row) { + this.languageDropdownRow = null; + } + } + + toggleLanguageDropdown(row) { + const panel = row.querySelector('[data-dub-language-panel="true"]'); + + if (!panel) { + return; + } + + if (this.languageDropdownRow && this.languageDropdownRow !== row) { + this.closeLanguageDropdown(this.languageDropdownRow); + } + + if (panel.hidden) { + this.openLanguageDropdown(row); + } else { + this.closeLanguageDropdown(row); + } + } + + createSettingRow() { + const row = createElement('li'); + row.dataset.watchloDubSetting = 'true'; + + const wrapper = createElement('div', 'flex items-center justify-between gap-4 px-5 py-3.5'); + const copy = createElement('div'); + const title = createElement('p', 'text-[14px] font-medium text-foreground', 'Dub Information'); + const dropdown = createElement('div', 'relative'); + const button = createElement('button', 'btn-depth flex items-center justify-between gap-2 min-w-[120px] h-9 px-3 rounded-[var(--radius-sm)] border border-white/[0.08] bg-[#181920] text-sm text-muted-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.03),0_14px_34px_-30px_rgba(0,0,0,0.82)]'); + const buttonLabel = createElement('span', null, getLanguageOption(this.config.language).label); + const chevron = createSvgElement('svg'); + const chevronPath = createSvgElement('path'); + const panel = createElement('div', 'absolute right-0 mt-2 w-48 origin-top-right rounded-[20px] bg-[linear-gradient(180deg,rgba(16,18,24,0.98),rgba(9,11,15,0.99))] border border-white/[0.1] shadow-[0_12px_40px_-12px_rgba(0,0,0,0.9)] backdrop-blur-md p-1.5 z-50'); + + button.type = 'button'; + button.dataset.dubLanguageButton = 'true'; + button.setAttribute('aria-haspopup', 'listbox'); + button.setAttribute('aria-expanded', 'false'); + + buttonLabel.dataset.dubLanguageLabel = 'true'; + + chevron.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + chevron.setAttribute('width', '16'); + chevron.setAttribute('height', '16'); + chevron.setAttribute('viewBox', '0 0 16 16'); + chevron.setAttribute('fill', 'none'); + chevron.setAttribute('stroke', 'currentColor'); + chevron.setAttribute('stroke-width', '1.75'); + chevron.setAttribute('stroke-linecap', 'round'); + chevron.setAttribute('stroke-linejoin', 'round'); + chevron.style.flexShrink = '0'; + + chevronPath.setAttribute('d', 'M4 6l4 4 4-4'); + + panel.dataset.dubLanguagePanel = 'true'; + panel.setAttribute('role', 'listbox'); + panel.hidden = true; + + for (const optionData of LANGUAGE_OPTIONS) { + const optionButton = createElement('button', optionData.value === this.config.language ? 'w-full px-3 py-2 text-sm rounded-[14px] bg-white/[0.09] text-foreground' : 'w-full px-3 py-2 text-sm rounded-[14px] text-foreground/82 hover:bg-white/[0.06]'); + optionButton.type = 'button'; + optionButton.dataset.dubLanguageOption = optionData.value; + optionButton.setAttribute('role', 'option'); + optionButton.setAttribute('aria-selected', optionData.value === this.config.language ? 'true' : 'false'); + optionButton.style.textAlign = 'left'; + optionButton.textContent = optionData.label; + panel.appendChild(optionButton); + } + + chevron.appendChild(chevronPath); + button.appendChild(buttonLabel); + button.appendChild(chevron); + dropdown.appendChild(button); + dropdown.appendChild(panel); + + copy.appendChild(title); + wrapper.appendChild(copy); + wrapper.appendChild(dropdown); + row.appendChild(wrapper); + + return row; + } + + updateSettingRow(row) { + const button = row.querySelector('[data-dub-language-button="true"]'); + const buttonLabel = row.querySelector('[data-dub-language-label="true"]'); + const panel = row.querySelector('[data-dub-language-panel="true"]'); + + if (!button || !buttonLabel || !panel) { + return; + } + + bindLanguageDropdownEvents(this); + + const selectedOption = getLanguageOption(this.config.language); + buttonLabel.textContent = selectedOption.label; + + const optionButtons = [...panel.querySelectorAll('[data-dub-language-option]')]; + for (const optionButton of optionButtons) { + const isSelected = optionButton.dataset.dubLanguageOption === selectedOption.value; + optionButton.className = isSelected ? 'w-full px-3 py-2 text-sm rounded-[14px] bg-white/[0.09] text-foreground' : 'w-full px-3 py-2 text-sm rounded-[14px] text-foreground/82 hover:bg-white/[0.06]'; + optionButton.setAttribute('aria-selected', isSelected ? 'true' : 'false'); + + if (!optionButton.dataset.bound) { + optionButton.addEventListener('click', () => { + const value = optionButton.dataset.dubLanguageOption; + if (!value) { + return; + } + + this.config.language = value; + this.saveConfig(); + this.closeLanguageDropdown(row); + this.updateSettingRow(row); + this.scheduleSync(); + }); + + optionButton.dataset.bound = 'true'; + } + } + + if (!button.dataset.bound) { + button.addEventListener('click', () => { + this.toggleLanguageDropdown(row); + }); + + button.dataset.bound = 'true'; + } + } + + stop() { + if (this.observer) { + this.observer.disconnect(); + } + + if (this.routeInterval) { + window.clearInterval(this.routeInterval); + } + } + } + + new WatchloDubInfo(); +})(); From 68f694c4e56ebaf31c1129386e4e94126b3bcf04 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sat, 2 May 2026 00:04:06 -0500 Subject: [PATCH 12/24] feat: add line ending normalization for consistent formatting --- .gitattributes | 1 + scripts/build.js | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cbdcbbc --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.js text eol=lf diff --git a/scripts/build.js b/scripts/build.js index 8a4cf1c..30fb6ad 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -80,6 +80,10 @@ function normalizeEnding(content) { return content.endsWith('\n') ? content : content + '\n'; } +function normalizeLineEndings(content) { + return content.replace(/\r\n?/g, '\n'); +} + // ----------------------------- // Pipeline // ----------------------------- @@ -102,9 +106,11 @@ async function processFile(file, options = {}) { // Write formatted source if it changed const out = (header ? header + '\n\n' : '') + beautifiedBody; const finalOut = normalizeEnding(out); + const normalizedSource = normalizeLineEndings(source); + const normalizedFinalOut = normalizeLineEndings(finalOut); let changed = false; - if (finalOut !== source) { + if (normalizedFinalOut !== normalizedSource) { await fs.writeFile(file, finalOut, 'utf8'); changed = true; console.log(`Formatted: ${path.relative(root, file)}`); @@ -123,7 +129,7 @@ async function processFile(file, options = {}) { let minChanged = true; try { const existing = await fs.readFile(outPath, 'utf8'); - if (existing === finalMinified) minChanged = false; + if (normalizeLineEndings(existing) === normalizeLineEndings(finalMinified)) minChanged = false; } catch (error) { if (error.code !== 'ENOENT') throw error; } From b70600ec55539c3d2c70936ea802465e441fc4ef Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sat, 2 May 2026 13:46:47 -0500 Subject: [PATCH 13/24] chore: deprecate both of these scripts.. Honestly youtube resumer while it works great still is sometimes just buggy and I'm honestly getting a little tired of maintaining it, I have since switched over to using https://github.com/Alplox/Youtube-Playback-Plox which while it does have some small bugs, at least I'm not the one having to deal wit maintenance. For teh watchlo dub info script, it's been baked into the site now. The script was short lived but that was what I was wanting in the first place. --- README.md | 1 - userscripts/watchlo-dub-info.user.js | 767 --------------------------- userscripts/youtube-resumer.user.js | 462 ---------------- 3 files changed, 1230 deletions(-) delete mode 100644 userscripts/watchlo-dub-info.user.js delete mode 100644 userscripts/youtube-resumer.user.js diff --git a/README.md b/README.md index 8b22bfd..1073731 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,6 @@ This table shows supported browsers and their compatible userscript managers. Cl | Nyaa - Tweaks | Redirects to English-translated anime and formats timestamps in 12-hour time. | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/nyaa-tweaks.user.js) | | ThePosterDB - Easy Links | Makes it easier to copy data from ThePosterDB | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/theposterdb-easy-links.user.js) | | Youtube - Filters | Advanced filtering for YouTube videos | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/youtube-filters.user.js) | -| YouTube - Resumer | Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup. | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/youtube-resumer.user.js) | | YouTube - Tweaks | Random tweaks and fixes for YouTube! | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/youtube-tweaks.user.js) | ## 🤝 Contributing diff --git a/userscripts/watchlo-dub-info.user.js b/userscripts/watchlo-dub-info.user.js deleted file mode 100644 index 79df106..0000000 --- a/userscripts/watchlo-dub-info.user.js +++ /dev/null @@ -1,767 +0,0 @@ -// ==UserScript== -// @name Watchlo Dub Info -// @version 0.1.4 -// @description Show dub availability for anime titles on Watchlo. -// @author Journey Over -// @license MIT -// @match https://watchlo.tv/* -// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/armhaglund/armhaglund.min.js -// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@644b86d55bf5816a4fa2a165bdb011ef7c22dfe1/libs/metadata/anilist/anilist.min.js -// @grant GM_getValue -// @grant GM_setValue -// @grant GM_xmlhttpRequest -// @grant GM_addStyle -// @run-at document-end -// @icon https://www.google.com/s2/favicons?sz=64&domain=watchlo.tv -// @homepageURL https://github.com/StylusThemes/Userscripts -// @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/watchlo-dub-info.user.js -// @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/watchlo-dub-info.user.js -// ==/UserScript== - -(function() { - 'use strict'; - - const CONFIG_KEY = 'watchlo-dub-config'; - const CACHE_PREFIX = 'watchlo-dub-cache'; - const CACHE_TTL = 24 * 60 * 60 * 1000; - const DEFAULT_CONFIG = { - enabled: true, - language: 'ENGLISH' - }; - - const LANGUAGE_OPTIONS = [ - { value: 'ENGLISH', label: 'English' }, - { value: 'JAPANESE', label: 'Japanese' }, - { value: 'PORTUGUESE', label: 'Portuguese (Brazil)' }, - { value: 'SPANISH', label: 'Spanish' }, - { value: 'FRENCH', label: 'French' }, - { value: 'GERMAN', label: 'German' }, - { value: 'ITALIAN', label: 'Italian' }, - { value: 'RUSSIAN', label: 'Russian' }, - { value: 'KOREAN', label: 'Korean' }, - { value: 'CHINESE', label: 'Chinese' } - ]; - - const LANGUAGE_LABELS = Object.fromEntries(LANGUAGE_OPTIONS.map(option => [option.value, option.label])); - - const anilist = new AniList(); - const armhaglund = new ArmHaglund(); - - function logError(...arguments_) { - globalThis.console.error('[Watchlo Dub Info]', ...arguments_); - } - - /** - * Create an HTML element with an optional class and text. - */ - function createElement(tagName, className, textContent) { - const element = document.createElement(tagName); - - if (className) { - element.className = className; - } - - if (textContent !== undefined) { - element.textContent = textContent; - } - - return element; - } - - /** - * Create an SVG element. - */ - function createSvgElement(tagName) { - return document.createElementNS('http://www.w3.org/2000/svg', tagName); - } - - /** - * Parse the current Watchlo URL into media metadata. - */ - function getMediaInfo() { - const match = location.pathname.match(/^\/(shows|movies)\/(\d+)-/); - - if (!match) { - return null; - } - - return { - mediaKind: match[1], - tmdbId: match[2] - }; - } - - function isRelevantPath() { - return location.pathname === '/settings' || getMediaInfo() !== null; - } - - /** - * Format a language code for display. - */ - function formatLanguageLabel(language) { - return LANGUAGE_LABELS[language] || language; - } - - function getLanguageOption(language) { - return LANGUAGE_OPTIONS.find(option => option.value === language) || LANGUAGE_OPTIONS[0]; - } - - function bindLanguageDropdownEvents(instance) { - if (instance.languageDropdownEventsBound) { - return; - } - - document.addEventListener('click', event => { - const row = instance.languageDropdownRow; - if (!row || !(event.target instanceof Node) || row.contains(event.target)) { - return; - } - - instance.closeLanguageDropdown(row); - }); - - document.addEventListener('keydown', event => { - if (event.key === 'Escape' && instance.languageDropdownRow) { - instance.closeLanguageDropdown(instance.languageDropdownRow); - } - }); - - instance.languageDropdownEventsBound = true; - } - - class WatchloDubInfo { - constructor() { - this.config = { ...DEFAULT_CONFIG }; - this.observer = null; - this.routeInterval = null; - this.syncTimer = null; - this.languageDropdownRow = null; - this.languageDropdownEventsBound = false; - this.pendingMediaKey = null; - this.activeMediaKey = null; - this.lastRoute = location.href; - this.lastHistoryState = history.state; - this.init(); - } - - /** - * Initialize config, observer, and first render pass. - */ - init() { - try { - this.loadConfig(); - this.startRouteWatcher(); - this.startDomObserver(); - if (isRelevantPath()) { - void this.handlePage(); - } - } catch (error) { - logError('Initialization failed', error); - } - } - - loadConfig() { - try { - const savedConfig = GM_getValue(CONFIG_KEY); - if (savedConfig && typeof savedConfig === 'object') { - this.config = { ...DEFAULT_CONFIG, ...savedConfig }; - } - } catch (error) { - logError('Failed to load config', error); - } - } - - saveConfig() { - try { - GM_setValue(CONFIG_KEY, { ...this.config }); - } catch (error) { - logError('Failed to save config', error); - } - } - - startRouteWatcher() { - const checkRoute = () => { - const currentHref = location.href; - const currentState = history.state; - - if (currentHref === this.lastRoute && currentState === this.lastHistoryState) { - return; - } - - this.lastRoute = currentHref; - this.lastHistoryState = currentState; - - if (isRelevantPath()) { - void this.handlePage(); - return; - } - - this.resetDetailState(); - this.removeDubInfo(); - }; - - const wrapHistoryMethod = methodName => { - const original = history[methodName]; - if (typeof original !== 'function' || original.__watchloDubWrapped) { - return; - } - - const wrapped = function() { - const result = original.apply(this, arguments); - window.dispatchEvent(new Event('watchlo-dub-routechange')); - return result; - }; - - wrapped.__watchloDubWrapped = true; - history[methodName] = wrapped; - }; - - wrapHistoryMethod('pushState'); - wrapHistoryMethod('replaceState'); - - window.addEventListener('popstate', checkRoute); - window.addEventListener('watchlo-dub-routechange', checkRoute); - - this.routeInterval = window.setInterval(checkRoute, 250); - } - - startDomObserver() { - const attachObserver = () => { - if (!document.body || this.observer) { - return; - } - - this.observer = new MutationObserver(() => this.scheduleSync()); - this.observer.observe(document.body, { - childList: true, - subtree: true - }); - }; - - if (document.body) { - attachObserver(); - return; - } - - document.addEventListener('DOMContentLoaded', attachObserver, { once: true }); - } - - scheduleSync() { - if (this.syncTimer !== null) { - return; - } - - this.syncTimer = window.setTimeout(() => { - this.syncTimer = null; - - if (isRelevantPath()) { - void this.handlePage(); - } - }, 50); - } - - /** - * Route the current page to the matching feature. - */ - async handlePage() { - try { - if (location.pathname === '/settings') { - this.syncSettingsPage(); - return; - } - - const mediaInfo = getMediaInfo(); - if (!mediaInfo) { - this.resetDetailState(); - this.removeDubInfo(); - return; - } - - await this.syncDetailPage(mediaInfo); - } catch (error) { - logError('Page handling failed', error); - } - } - - resetDetailState() { - this.pendingMediaKey = null; - this.activeMediaKey = null; - } - - removeDubInfo() { - const node = document.querySelector('[data-watchlo-dub-info="true"]'); - if (node) { - node.remove(); - } - } - - getMediaKey(mediaInfo) { - return `${mediaInfo.mediaKind}:${mediaInfo.tmdbId}`; - } - - getCacheKey(mediaInfo) { - return `${CACHE_PREFIX}:${mediaInfo.mediaKind}:${mediaInfo.tmdbId}`; - } - - isCacheValid(cache) { - return !!cache?.checkedAt && Date.now() - cache.checkedAt < CACHE_TTL; - } - - getCachedResult(mediaInfo) { - try { - const cache = GM_getValue(this.getCacheKey(mediaInfo)); - - if (!this.isCacheValid(cache) || cache?.anilistId == null) { - return null; - } - - const cachedLanguageResult = cache.dubByLanguage?.[this.config.language]; - return cachedLanguageResult === undefined ? null : cachedLanguageResult; - } catch (error) { - logError('Failed reading cache', error); - return null; - } - } - - cacheConfirmedResult(mediaInfo, anilistId, language, hasDub) { - try { - const cacheKey = this.getCacheKey(mediaInfo); - const currentCache = GM_getValue(cacheKey) || {}; - - GM_setValue(cacheKey, { - checkedAt: Date.now(), - anilistId, - dubByLanguage: { - ...(currentCache.dubByLanguage || {}), - [language]: hasDub - } - }); - } catch (error) { - logError('Failed writing cache', error); - } - } - - /** - * Resolve AniList ID and render the dub marker when available. - */ - async syncDetailPage(mediaInfo) { - const mediaKey = this.getMediaKey(mediaInfo); - const existingNode = document.querySelector('[data-watchlo-dub-info="true"]'); - - if (!this.config.enabled) { - this.removeDubInfo(); - this.resetDetailState(); - return; - } - - if (this.activeMediaKey && this.activeMediaKey !== mediaKey) { - this.removeDubInfo(); - this.resetDetailState(); - } - - if (existingNode) { - this.activeMediaKey = mediaKey; - return; - } - - const cachedResult = this.getCachedResult(mediaInfo); - if (cachedResult !== null) { - this.activeMediaKey = mediaKey; - - if (cachedResult) { - this.insertDubInfo(mediaInfo); - } - - return; - } - - if (this.pendingMediaKey === mediaKey || this.activeMediaKey === mediaKey) { - return; - } - - this.pendingMediaKey = mediaKey; - - try { - const anilistId = await this.resolveAniListId(mediaInfo); - if (!anilistId) { - logError('AniList ID not resolved', mediaInfo); - return; - } - - const hasDub = await this.queryAniListDub(anilistId, this.config.language); - if (this.getMediaKey(getMediaInfo() || mediaInfo) !== mediaKey) { - return; - } - - this.cacheConfirmedResult(mediaInfo, anilistId, this.config.language, hasDub); - - if (hasDub) { - this.insertDubInfo(mediaInfo); - } - - this.activeMediaKey = mediaKey; - } catch (error) { - logError('Detail page processing failed', error); - this.activeMediaKey = mediaKey; - } finally { - if (this.pendingMediaKey === mediaKey) { - this.pendingMediaKey = null; - } - } - } - - async resolveAniListId() { - try { - const anilistLink = document.querySelector('a[href*="anilist.co/anime/"]'); - const href = anilistLink?.getAttribute('href') || ''; - const match = href.match(/\/anime\/(\d+)(?:\/|$)/); - let anilistId = match ? match[1] : null; - - const tmdbLink = document.querySelector('a[href*="themoviedb.org/tv/"], a[href*="themoviedb.org/movie/"]'); - const tmdbId = tmdbLink?.href.match(/\/(?:tv|movie)\/(\d+)(?:\/)?$/)?.[1] || null; - - if (!anilistId && tmdbId) { - try { - const ids = await armhaglund.fetchIds('themoviedb', tmdbId); - anilistId = ids?.anilist ? String(ids.anilist) : null; - } catch (error) { - logError('ArmHaglund fallback failed', error.message); - } - } - - return anilistId; - } catch (error) { - logError('Failed to resolve AniList ID', error); - return null; - } - } - - /** - * Query AniList for a dub match. - */ - async queryAniListDub(anilistId, language) { - const query = ` - query($id: Int!, $type: MediaType, $page: Int = 1, $language: StaffLanguage) { - Media(id: $id, type: $type) { - characters(page: $page, sort: [ROLE], role: MAIN) { - edges { - node { id } - voiceActors(language: $language) { - language - } - } - } - } - } - `; - - const allResults = []; - - for (let page = 1; page <= 3; page++) { - try { - const response = await anilist.query(query, { - id: Number(anilistId), - type: 'ANIME', - page, - language - }); - - const edges = response?.data?.Media?.characters?.edges || []; - allResults.push(...edges); - - if (edges.length === 0) { - break; - } - } catch { - break; - } - } - - return allResults.some(edge => (edge?.voiceActors?.length || 0) > 0); - } - - /** - * Insert the dub badge after the Japan/Japanese metadata row. - */ - insertDubInfo() { - if (document.querySelector('[data-watchlo-dub-info="true"]')) { - return; - } - - const anchor = this.findMetadataAnchor(); - if (!anchor) { - return; - } - - const label = formatLanguageLabel(this.config.language); - const dubNode = this.createDubNode(label); - anchor.after(dubNode); - } - - findMetadataAnchor() { - const candidates = [...document.querySelectorAll('.flex.items-center')]; - return candidates.find(candidate => { - const text = (candidate.textContent || '').trim(); - return /^Japanese$/i.test(text) || /\bJapanese\b/i.test(text); - }) || null; - } - - createDubNode(languageLabel) { - const node = createElement('div', 'flex items-center gap-1.5'); - node.dataset.watchloDubInfo = 'true'; - - const svg = createSvgElement('svg'); - svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - svg.setAttribute('width', '24'); - svg.setAttribute('height', '24'); - svg.setAttribute('viewBox', '0 0 24 24'); - svg.setAttribute('fill', 'none'); - svg.setAttribute('stroke', 'currentColor'); - svg.setAttribute('stroke-width', '2'); - svg.setAttribute('stroke-linecap', 'round'); - svg.setAttribute('stroke-linejoin', 'round'); - svg.classList.add('h-3.5', 'w-3.5', 'text-muted-foreground/80'); - - const path = createSvgElement('path'); - path.setAttribute('d', 'M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3'); - - const text = createElement('span', 'text-[13px] text-muted-foreground/80', `${languageLabel} Dub Exists`); - - svg.appendChild(path); - node.appendChild(svg); - node.appendChild(text); - - return node; - } - - /** - * Inject or refresh the settings toggle row. - */ - syncSettingsPage() { - const section = this.findPreferencesSection(); - if (!section) { - return; - } - - let row = section.querySelector('[data-watchlo-dub-setting="true"]'); - if (!row) { - row = this.createSettingRow(); - } - - const anchor = this.findAnimeDisplayModeRow(section); - if (anchor) { - anchor.after(row); - } else { - const list = section.querySelector('ul, ol, [role="list"]'); - if (list) { - list.appendChild(row); - } else { - section.appendChild(row); - } - } - - this.updateSettingRow(row); - } - - findPreferencesSection() { - const sections = [...document.querySelectorAll('section')]; - - for (const section of sections) { - const heading = section.querySelector('h1, h2, h3, h4, h5, h6'); - if (heading && /Preferences/i.test(heading.textContent || '')) { - return section; - } - } - - return null; - } - - findAnimeDisplayModeRow(section) { - const candidates = [...section.querySelectorAll('li, [role="listitem"], label, p, span, div')]; - - for (const candidate of candidates) { - const text = (candidate.textContent || '').replace(/\s+/g, ' ').trim(); - if (!/Anime Display Mode/i.test(text)) { - continue; - } - - const row = candidate.closest('li, [role="listitem"]'); - if (row && row !== section) { - return row; - } - } - - return null; - } - - openLanguageDropdown(row) { - const panel = row.querySelector('[data-dub-language-panel="true"]'); - const button = row.querySelector('[data-dub-language-button="true"]'); - - if (!panel || !button) { - return; - } - - this.languageDropdownRow = row; - panel.hidden = false; - button.setAttribute('aria-expanded', 'true'); - } - - closeLanguageDropdown(row) { - const panel = row?.querySelector('[data-dub-language-panel="true"]'); - const button = row?.querySelector('[data-dub-language-button="true"]'); - - if (panel) { - panel.hidden = true; - } - - if (button) { - button.setAttribute('aria-expanded', 'false'); - } - - if (this.languageDropdownRow === row) { - this.languageDropdownRow = null; - } - } - - toggleLanguageDropdown(row) { - const panel = row.querySelector('[data-dub-language-panel="true"]'); - - if (!panel) { - return; - } - - if (this.languageDropdownRow && this.languageDropdownRow !== row) { - this.closeLanguageDropdown(this.languageDropdownRow); - } - - if (panel.hidden) { - this.openLanguageDropdown(row); - } else { - this.closeLanguageDropdown(row); - } - } - - createSettingRow() { - const row = createElement('li'); - row.dataset.watchloDubSetting = 'true'; - - const wrapper = createElement('div', 'flex items-center justify-between gap-4 px-5 py-3.5'); - const copy = createElement('div'); - const title = createElement('p', 'text-[14px] font-medium text-foreground', 'Dub Information'); - const dropdown = createElement('div', 'relative'); - const button = createElement('button', 'btn-depth flex items-center justify-between gap-2 min-w-[120px] h-9 px-3 rounded-[var(--radius-sm)] border border-white/[0.08] bg-[#181920] text-sm text-muted-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.03),0_14px_34px_-30px_rgba(0,0,0,0.82)]'); - const buttonLabel = createElement('span', null, getLanguageOption(this.config.language).label); - const chevron = createSvgElement('svg'); - const chevronPath = createSvgElement('path'); - const panel = createElement('div', 'absolute right-0 mt-2 w-48 origin-top-right rounded-[20px] bg-[linear-gradient(180deg,rgba(16,18,24,0.98),rgba(9,11,15,0.99))] border border-white/[0.1] shadow-[0_12px_40px_-12px_rgba(0,0,0,0.9)] backdrop-blur-md p-1.5 z-50'); - - button.type = 'button'; - button.dataset.dubLanguageButton = 'true'; - button.setAttribute('aria-haspopup', 'listbox'); - button.setAttribute('aria-expanded', 'false'); - - buttonLabel.dataset.dubLanguageLabel = 'true'; - - chevron.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - chevron.setAttribute('width', '16'); - chevron.setAttribute('height', '16'); - chevron.setAttribute('viewBox', '0 0 16 16'); - chevron.setAttribute('fill', 'none'); - chevron.setAttribute('stroke', 'currentColor'); - chevron.setAttribute('stroke-width', '1.75'); - chevron.setAttribute('stroke-linecap', 'round'); - chevron.setAttribute('stroke-linejoin', 'round'); - chevron.style.flexShrink = '0'; - - chevronPath.setAttribute('d', 'M4 6l4 4 4-4'); - - panel.dataset.dubLanguagePanel = 'true'; - panel.setAttribute('role', 'listbox'); - panel.hidden = true; - - for (const optionData of LANGUAGE_OPTIONS) { - const optionButton = createElement('button', optionData.value === this.config.language ? 'w-full px-3 py-2 text-sm rounded-[14px] bg-white/[0.09] text-foreground' : 'w-full px-3 py-2 text-sm rounded-[14px] text-foreground/82 hover:bg-white/[0.06]'); - optionButton.type = 'button'; - optionButton.dataset.dubLanguageOption = optionData.value; - optionButton.setAttribute('role', 'option'); - optionButton.setAttribute('aria-selected', optionData.value === this.config.language ? 'true' : 'false'); - optionButton.style.textAlign = 'left'; - optionButton.textContent = optionData.label; - panel.appendChild(optionButton); - } - - chevron.appendChild(chevronPath); - button.appendChild(buttonLabel); - button.appendChild(chevron); - dropdown.appendChild(button); - dropdown.appendChild(panel); - - copy.appendChild(title); - wrapper.appendChild(copy); - wrapper.appendChild(dropdown); - row.appendChild(wrapper); - - return row; - } - - updateSettingRow(row) { - const button = row.querySelector('[data-dub-language-button="true"]'); - const buttonLabel = row.querySelector('[data-dub-language-label="true"]'); - const panel = row.querySelector('[data-dub-language-panel="true"]'); - - if (!button || !buttonLabel || !panel) { - return; - } - - bindLanguageDropdownEvents(this); - - const selectedOption = getLanguageOption(this.config.language); - buttonLabel.textContent = selectedOption.label; - - const optionButtons = [...panel.querySelectorAll('[data-dub-language-option]')]; - for (const optionButton of optionButtons) { - const isSelected = optionButton.dataset.dubLanguageOption === selectedOption.value; - optionButton.className = isSelected ? 'w-full px-3 py-2 text-sm rounded-[14px] bg-white/[0.09] text-foreground' : 'w-full px-3 py-2 text-sm rounded-[14px] text-foreground/82 hover:bg-white/[0.06]'; - optionButton.setAttribute('aria-selected', isSelected ? 'true' : 'false'); - - if (!optionButton.dataset.bound) { - optionButton.addEventListener('click', () => { - const value = optionButton.dataset.dubLanguageOption; - if (!value) { - return; - } - - this.config.language = value; - this.saveConfig(); - this.closeLanguageDropdown(row); - this.updateSettingRow(row); - this.scheduleSync(); - }); - - optionButton.dataset.bound = 'true'; - } - } - - if (!button.dataset.bound) { - button.addEventListener('click', () => { - this.toggleLanguageDropdown(row); - }); - - button.dataset.bound = 'true'; - } - } - - stop() { - if (this.observer) { - this.observer.disconnect(); - } - - if (this.routeInterval) { - window.clearInterval(this.routeInterval); - } - } - } - - new WatchloDubInfo(); -})(); diff --git a/userscripts/youtube-resumer.user.js b/userscripts/youtube-resumer.user.js deleted file mode 100644 index c91434b..0000000 --- a/userscripts/youtube-resumer.user.js +++ /dev/null @@ -1,462 +0,0 @@ -// ==UserScript== -// @name YouTube - Resumer -// @version 2.3.0 -// @description Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup. -// @author Journey Over -// @license MIT -// @match *://*.youtube.com/* -// @match *://*.youtube-nocookie.com/* -// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/libs/utils/utils.min.js -// @grant GM_setValue -// @grant GM_getValue -// @grant GM_deleteValue -// @grant GM_listValues -// @grant GM_addValueChangeListener -// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com -// @homepageURL https://github.com/StylusThemes/Userscripts -// @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/youtube-resumer.user.js -// @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/youtube-resumer.user.js -// ==/UserScript== - -(function() { - 'use strict'; - - const logger = Logger('YT - Resumer', { debug: false }); - - const CONFIG = { - MIN_SEEK_DIFFERENCE: 1.5, - SEEK_VERIFY_DELAY_MS: 250, - RESUME_SETTLE_DELAY_MS: 200, - PLAYER_READY_POLL_MS: 100, - PLAYER_READY_MAX_ATTEMPTS: 20, - SAVE_THROTTLE_MS: 1000, - SEEK_TIMEOUT_MS: 2000, - CLEANUP_INTERVAL_MS: 5 * 60 * 1000, - PREVIEW_VISIBILITY_THRESHOLD: 0.5, - RETENTION_DAYS: { regular: 90, short: 1, preview: 10 / (24 * 60) }, - }; - - const STORAGE_KEY = 'yt_resumer_storage'; - const SEEK_LOCK_PROP = '_ytResumerSeekPending'; - const REMOTE_UPDATE_EVENT = 'yt-resumer-remote-update'; - const SEEK_RELEASE_EVENTS = ['seeked', 'abort', 'emptied', 'error']; - - let activeAbortController = null; - let activeVideoContext = { videoId: null, playlistId: null }; - let previousPlaylistId = null; - - // ── Utilities ── - - const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); - - const formatTime = seconds => { - const total = Math.floor(seconds); - const hours = String(Math.floor(total / 3600)).padStart(2, '0'); - const minutes = String(Math.floor((total % 3600) / 60)).padStart(2, '0'); - const secs = String(total % 60).padStart(2, '0'); - return `${seconds.toFixed(2)}s (${hours}:${minutes}:${secs})`; - }; - - // ── Storage ── - - const Storage = { - read() { - return GM_getValue(STORAGE_KEY) || { videos: {}, playlists: {}, meta: {} }; - }, - - write(data) { - GM_setValue(STORAGE_KEY, data); - }, - - saveProgress(videoId, currentTime, videoType, playlistId) { - if (!currentTime || currentTime < 1) return; - - try { - const data = this.read(); - - if (playlistId) { - data.playlists[playlistId] = data.playlists[playlistId] || { lastWatchedVideoId: '', videos: {} }; - data.playlists[playlistId].videos[videoId] = { timestamp: currentTime, lastUpdated: Date.now(), videoType }; - data.playlists[playlistId].lastWatchedVideoId = videoId; - } else { - data.videos[videoId] = { timestamp: currentTime, lastUpdated: Date.now(), videoType }; - } - - this.write(data); - } catch (error) { - logger.error('Failed to save progress', error); - } - }, - - getResumeInfo(videoId, playlistId) { - const data = this.read(); - - if (playlistId) { - const playlistData = data.playlists[playlistId]; - if (!playlistData?.videos) return null; - - let targetVideoId = videoId; - const lastWatchedId = playlistData.lastWatchedVideoId; - if (playlistId !== previousPlaylistId && lastWatchedId && videoId !== lastWatchedId) { - targetVideoId = lastWatchedId; - } - - const timestamp = playlistData.videos[targetVideoId]?.timestamp; - return timestamp ? { targetVideoId, timestamp, inPlaylist: true } : null; - } - - const timestamp = data.videos[videoId]?.timestamp; - return timestamp ? { targetVideoId: videoId, timestamp, inPlaylist: false } : null; - }, - - cleanup() { - const data = this.read(); - const now = Date.now(); - - for (const videoId of Object.keys(data.videos)) { - if (this._isExpired(data.videos[videoId], now)) delete data.videos[videoId]; - } - - for (const playlistId of Object.keys(data.playlists)) { - const playlist = data.playlists[playlistId]; - for (const videoId of Object.keys(playlist.videos)) { - if (this._isExpired(playlist.videos[videoId], now)) delete playlist.videos[videoId]; - } - if (Object.keys(playlist.videos).length === 0) delete data.playlists[playlistId]; - } - - this.write(data); - }, - - runPeriodicCleanup() { - const data = this.read(); - const lastCleanup = data.meta.lastCleanup || 0; - if (Date.now() - lastCleanup < CONFIG.CLEANUP_INTERVAL_MS) return; - - data.meta.lastCleanup = Date.now(); - this.write(data); - logger('Running scheduled cleanup'); - this.cleanup(); - }, - - _isExpired(entry, now) { - if (!entry?.lastUpdated) return true; - const daysToKeep = CONFIG.RETENTION_DAYS[entry.videoType] ?? CONFIG.RETENTION_DAYS.regular; - return now - entry.lastUpdated > daysToKeep * 86_400_000; - }, - }; - - // ── Seeking ── - - async function waitForPlayerReady(player) { - let attempts = 0; - while (typeof player.getPlayerState !== 'function' || player.getPlayerState() === -1) { - if (attempts++ > CONFIG.PLAYER_READY_MAX_ATTEMPTS) return; - await wait(CONFIG.PLAYER_READY_POLL_MS); - } - } - - async function seekVideo(player, videoElement, time) { - if (!player || !videoElement || isNaN(time)) return; - if (Math.abs(player.getCurrentTime() - time) < CONFIG.MIN_SEEK_DIFFERENCE) return; - - await waitForPlayerReady(player); - - logger.debug('Seeking video', { currentTime: player.getCurrentTime(), targetTime: time }); - - if (videoElement.seeking && !videoElement[SEEK_LOCK_PROP]) { - videoElement.addEventListener('seeked', () => { - setTimeout(() => seekVideo(player, videoElement, time), 0); - }, { once: true }); - return; - } - - videoElement[SEEK_LOCK_PROP] = true; - - const releaseLock = () => { - videoElement[SEEK_LOCK_PROP] = false; - clearTimeout(fallbackTimer); - for (const event of SEEK_RELEASE_EVENTS) videoElement.removeEventListener(event, releaseLock); - }; - - for (const event of SEEK_RELEASE_EVENTS) videoElement.addEventListener(event, releaseLock, { once: true }); - const fallbackTimer = setTimeout(releaseLock, CONFIG.SEEK_TIMEOUT_MS); - - player.seekTo(time, true, { skipBufferingCheck: window.location.pathname === '/' }); - - // YouTube often resets to 0 shortly after load; verify and re-seek if needed - await wait(CONFIG.SEEK_VERIFY_DELAY_MS); - - if (player.getCurrentTime() < 1 && time > CONFIG.MIN_SEEK_DIFFERENCE) { - logger.debug('Detected reset to 0, re-seeking...'); - player.seekTo(time, true); - } - } - - // ── Playlist Resolution ── - - function waitForPlaylist(player) { - logger.debug('Waiting for playlist data'); - - return new Promise((resolve, reject) => { - const existing = player.getPlaylist(); - if (existing?.length) return resolve(existing); - - let settled = false; - let pollTimer = null; - let pollAttempts = 0; - - const cleanup = () => { - document.removeEventListener('yt-playlist-data-updated', check); - clearInterval(pollTimer); - }; - - const finish = (result) => { - if (settled) return; - settled = true; - cleanup(); - resolve(result); - }; - - const check = () => { - const playlist = player.getPlaylist(); - if (playlist?.length) finish(playlist); - }; - - document.addEventListener('yt-playlist-data-updated', check, { once: true }); - - pollTimer = setInterval(() => { - check(); - if (!settled && ++pollAttempts > 50) { - settled = true; - cleanup(); - reject(new Error('Playlist not found')); - } - }, 100); - }); - } - - // ── Resume ── - - async function resumePlayback(player, videoId, videoElement, playlistId) { - try { - const resumeInfo = Storage.getResumeInfo(videoId, playlistId); - if (!resumeInfo) return; - - logger('Resuming playback', { - videoId: resumeInfo.targetVideoId, - resumeTime: formatTime(resumeInfo.timestamp), - inPlaylist: resumeInfo.inPlaylist, - }); - - if (resumeInfo.inPlaylist && videoId !== resumeInfo.targetVideoId) { - const playlistVideos = await waitForPlaylist(player); - const videoIndex = playlistVideos.indexOf(resumeInfo.targetVideoId); - if (videoIndex !== -1) player.playVideoAt(videoIndex); - } else { - await seekVideo(player, videoElement, resumeInfo.timestamp); - } - } catch (error) { - logger.error('Failed to resume playback', error); - } - } - - // ── Video Handler ── - - function parseVideoInfo(playerContainer, player) { - const parameters = new URLSearchParams(window.location.search); - const videoId = parameters.get('v') || player.getVideoData()?.video_id; - const rawPlaylistId = parameters.get('list'); - const playlistId = rawPlaylistId !== 'WL' ? rawPlaylistId : null; - const isPreview = playerContainer.id === 'inline-player'; - - let videoType = 'regular'; - if (window.location.pathname.startsWith('/shorts/')) videoType = 'short'; - else if (isPreview) videoType = 'preview'; - - return { - videoId, - playlistId, - videoType, - isPreview, - isLive: player.getVideoData()?.isLive, - hasExplicitTime: parameters.has('t'), - }; - } - - function handleVideo(playerContainer, player, videoElement) { - if (activeAbortController) activeAbortController.abort(); - activeVideoContext = { videoId: null, playlistId: null }; - activeAbortController = new AbortController(); - const { signal } = activeAbortController; - - const info = parseVideoInfo(playerContainer, player); - if (!info.videoId) return; - - activeVideoContext = { videoId: info.videoId, playlistId: info.playlistId }; - - if (info.isLive || info.hasExplicitTime) { - previousPlaylistId = info.playlistId; - return; - } - - logger.debug('Handling video', { videoId: info.videoId }); - - let hasResumed = false; - let isResuming = false; - let lastSaveTime = Date.now(); - - const attachListeners = () => { - const attemptResume = () => { - if (hasResumed || isResuming) return; - isResuming = true; - setTimeout(() => { - resumePlayback(player, info.videoId, videoElement, info.playlistId).then(() => { - hasResumed = true; - isResuming = false; - lastSaveTime = Date.now(); - }); - }, CONFIG.RESUME_SETTLE_DELAY_MS); - }; - - const onTimeUpdate = () => { - const adPlaying = playerContainer.classList.contains('ad-showing') || playerContainer.classList.contains('ad-interrupting'); - if (adPlaying || isResuming || videoElement[SEEK_LOCK_PROP]) return; - - if (hasResumed) { - const now = Date.now(); - if (now - lastSaveTime > CONFIG.SAVE_THROTTLE_MS) { - const videoId = player.getVideoData()?.video_id; - if (videoId) { - Storage.saveProgress(videoId, videoElement.currentTime, info.videoType, info.playlistId); - lastSaveTime = now; - } - } - } - }; - - const onRemoteUpdate = async (event_) => { - logger.debug('Remote update received', { time: event_.detail.time }); - await seekVideo(player, videoElement, event_.detail.time); - }; - - videoElement.addEventListener('play', attemptResume, { signal, once: true }); - videoElement.addEventListener('timeupdate', onTimeUpdate, { signal }); - window.addEventListener(REMOTE_UPDATE_EVENT, onRemoteUpdate, { signal }); - }; - - if (info.isPreview) { - const observer = new IntersectionObserver((entries) => { - for (const entry of entries) { - if (entry.isIntersecting && !signal.aborted) { - attachListeners(); - observer.disconnect(); - } - } - }, { threshold: CONFIG.PREVIEW_VISIBILITY_THRESHOLD }); - observer.observe(playerContainer); - } else { - attachListeners(); - } - - previousPlaylistId = info.playlistId; - } - - // ── Cross-Tab Sync ── - - function onStorageChange(_key, _oldValue, newValue, isRemote) { - if (!isRemote || !newValue) return; - - logger.debug('Remote storage change detected'); - - const { videoId, playlistId } = activeVideoContext; - let resumeTime; - - if (playlistId) { - resumeTime = newValue.playlists?.[playlistId]?.videos?.[videoId]?.timestamp; - } else if (videoId) { - resumeTime = newValue.videos?.[videoId]?.timestamp; - } - - if (resumeTime) { - window.dispatchEvent(new CustomEvent(REMOTE_UPDATE_EVENT, { detail: { time: resumeTime } })); - } - } - - // ── Timestamp Link Interception ── - - function interceptTimestampLinks() { - document.documentElement.addEventListener('click', (event) => { - if (!(event.target instanceof Element)) return; - - const anchor = event.target.closest('a'); - if (!anchor?.href || !/[?&]t=/.test(anchor.href)) return; - - // Allow native timestamp clicks inside comments and descriptions - if (anchor.closest('ytd-comments, ytd-text-inline-expander, #description, #content-text')) return; - - if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) return; - - try { - const url = new URL(anchor.href); - if (!url.searchParams.has('t')) return; - - logger.debug('Intercepting timestamp link', { originalUrl: anchor.href }); - url.searchParams.delete('t'); - const cleanUrl = url.toString(); - anchor.href = cleanUrl; - - event.preventDefault(); - event.stopImmediatePropagation(); - history.pushState(null, '', cleanUrl); - window.dispatchEvent(new PopStateEvent('popstate', { state: null })); - } catch (error) { - logger('Could not modify link href:', error); - } - }, true); - } - - // ── Bootstrap ── - - function initVideoLoad() { - const player = document.querySelector('#movie_player'); - if (!player) return; - const video = player.querySelector('video'); - if (video) handleVideo(player, player.player_ || player, video); - } - - function onPlayerContainerLoad(event_) { - const container = event_.target; - const playerInstance = container?.player_; - const video = container?.querySelector('video'); - if (playerInstance && video) handleVideo(container, playerInstance, video); - } - - function init() { - try { - logger('Initializing YouTube Resumer'); - - window.addEventListener('pagehide', () => { - activeAbortController?.abort(); - activeVideoContext = { videoId: null, playlistId: null }; - }, true); - - Storage.runPeriodicCleanup(); - setInterval(() => Storage.runPeriodicCleanup(), CONFIG.CLEANUP_INTERVAL_MS); - - GM_addValueChangeListener(STORAGE_KEY, onStorageChange); - interceptTimestampLinks(); - - window.addEventListener('pageshow', () => { - logger('Handling video load'); - initVideoLoad(); - window.addEventListener('yt-player-updated', onPlayerContainerLoad, true); - }, { once: true }); - } catch (error) { - logger.error('Initialization failed', error); - } - } - - init(); - -})(); From b6fe7951e177e74ccd7bc3efbfa725f1ace3ea92 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 3 May 2026 20:55:28 -0500 Subject: [PATCH 14/24] fix: restore button injection after layout change --- userscripts/dmm-add-trash-buttons.user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/userscripts/dmm-add-trash-buttons.user.js b/userscripts/dmm-add-trash-buttons.user.js index 39a5ca0..097578e 100644 --- a/userscripts/dmm-add-trash-buttons.user.js +++ b/userscripts/dmm-add-trash-buttons.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name DMM - Add Trash Guide Regex Buttons -// @version 4.0.0 +// @version 4.0.1 // @description Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns. // @author Journey Over // @license MIT @@ -42,6 +42,7 @@ CACHE_DURATION: 24 * 60 * 60 * 1000, MUTATION_DEBOUNCE: 150, EXTERNAL_BUTTON_CONTAINERS: [ + 'div[data-testid="media-header-actions"]', '.grid > div:last-child', '.flex.flex-col.gap-2 > div:last-child', 'div[class*="gap-2"] > div:last-child', From a170ade9be4b105066b8fd01c2767b0394b50591 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 3 May 2026 21:01:09 -0500 Subject: [PATCH 15/24] fix: support movie pages without data-testid Didn't fully test movie pages like a derp...this should fix it on movie pages so now it should work for both tv and movie pages correctly again! --- userscripts/dmm-add-trash-buttons.user.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/userscripts/dmm-add-trash-buttons.user.js b/userscripts/dmm-add-trash-buttons.user.js index 097578e..45ea0cb 100644 --- a/userscripts/dmm-add-trash-buttons.user.js +++ b/userscripts/dmm-add-trash-buttons.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name DMM - Add Trash Guide Regex Buttons -// @version 4.0.1 +// @version 4.0.2 // @description Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns. // @author Journey Over // @license MIT @@ -43,6 +43,7 @@ MUTATION_DEBOUNCE: 150, EXTERNAL_BUTTON_CONTAINERS: [ 'div[data-testid="media-header-actions"]', + '.flex.min-w-0.flex-col.gap-2 > .flex.flex-wrap.items-center.gap-2', '.grid > div:last-child', '.flex.flex-col.gap-2 > div:last-child', 'div[class*="gap-2"] > div:last-child', From c3be58bbc7cae4eae48c40619e871ec9079a6b5a Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Mon, 4 May 2026 13:46:31 -0500 Subject: [PATCH 16/24] fix: remove some deadcode and update some other small things. --- .../nexusmods-updated-mod-highlighter.user.js | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/userscripts/nexusmods-updated-mod-highlighter.user.js b/userscripts/nexusmods-updated-mod-highlighter.user.js index 4d5a9b5..39833a3 100644 --- a/userscripts/nexusmods-updated-mod-highlighter.user.js +++ b/userscripts/nexusmods-updated-mod-highlighter.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Nexus Mods - Updated Mod Highlighter -// @version 2.1.1 +// @version 2.1.2 // @description Highlight mods that have updated since you last downloaded them // @author Journey Over // @license MIT @@ -19,6 +19,7 @@ const CONFIG = { table: { highlightClass: 'nm-update-row', + styleId: 'nexus-updated-style', }, tile: { styleId: 'nm-highlighter-style', @@ -55,12 +56,11 @@ const ANIMATION_DURATIONS = { TILE_GLOW: 2, - TILE_PULSE: 2.5, TABLE_GLOW: 3, TABLE_STRIPE: 4, }; - const PAGE_SELECTORS = { + const PAGE_ROUTES = { DOWNLOAD_HISTORY: { path: '/users/myaccount', tab: 'tab=download+history' @@ -82,12 +82,8 @@ } isDownloadHistoryPage() { - return window.location.pathname.includes(PAGE_SELECTORS.DOWNLOAD_HISTORY.path) && - window.location.search.includes(PAGE_SELECTORS.DOWNLOAD_HISTORY.tab); - } - - getTileSelector() { - return CONFIG.tile.selectors.join(', '); + return window.location.pathname.includes(PAGE_ROUTES.DOWNLOAD_HISTORY.path) && + window.location.search.includes(PAGE_ROUTES.DOWNLOAD_HISTORY.tab); } injectStyleElement(styleId, styleCss) { @@ -102,7 +98,7 @@ injectTableStyles() { const updateColors = CONFIG.tile.colors.update; const css = `@keyframes table-row-glow{0%,100%{box-shadow:inset 0 0 8px ${updateColors.glow.replace('0.4','0.1')},0 0 4px ${updateColors.glow.replace('0.4','0.2')};background:linear-gradient(90deg,${updateColors.bg} 0%,${updateColors.bg.replace('0.05','0.08')} 50%,${updateColors.bg} 100%)}50%{box-shadow:inset 0 0 12px ${updateColors.glow.replace('0.4','0.15')},0 0 8px ${updateColors.glow.replace('0.4','0.3')};background:linear-gradient(90deg,${updateColors.bg.replace('0.05','0.08')} 0%,${updateColors.bg.replace('0.05','0.12')} 50%,${updateColors.bg.replace('0.05','0.08')} 100%)}}@keyframes table-stripe{0%{background-position:-200% 0}100%{background-position:200% 0}}.${CONFIG.table.highlightClass}{position:relative;animation:table-row-glow ${ANIMATION_DURATIONS.TABLE_GLOW}s ease-in-out infinite;background:linear-gradient(90deg,${updateColors.bg.replace('0.05','0.03')} 0%,${updateColors.bg.replace('0.05','0.06')} 50%,${updateColors.bg.replace('0.05','0.03')} 100%);background-size:200% 100%;transition:all 0.3s ease}.${CONFIG.table.highlightClass}::before{content:'';position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(90deg,transparent 0%,${updateColors.glow.replace('0.4','0.1')} 20%,${updateColors.glow.replace('0.4','0.2')} 50%,${updateColors.glow.replace('0.4','0.1')} 80%,transparent 100%);background-size:200% 100%;animation:table-stripe ${ANIMATION_DURATIONS.TABLE_STRIPE}s linear infinite;pointer-events:none;z-index:1}.${CONFIG.table.highlightClass}::after{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;background:linear-gradient(180deg,${updateColors.primary} 0%,${updateColors.secondary} 50%,${updateColors.primary} 100%);box-shadow:0 0 8px ${updateColors.glow}}`; - this.injectStyleElement('nexus-updated-style', css); + this.injectStyleElement(CONFIG.table.styleId, css); } injectTileStyles() { @@ -143,7 +139,7 @@ processTiles() { if (this.isDownloadHistoryPage()) return; - const tileSelectorString = this.getTileSelector(); + const tileSelectorString = CONFIG.tile.selectors.join(', '); const tileElements = document.querySelectorAll(tileSelectorString); for (const tileElement of tileElements) { tileElement.classList.remove(CONFIG.tile.updateClass, CONFIG.tile.downloadClass); @@ -178,13 +174,13 @@ setupNavigationHooks() { const originalPushState = history.pushState; const originalReplaceState = history.replaceState; - history.pushState = (...stateArguments) => { - const result = originalPushState.apply(history, stateArguments); + history.pushState = (...arguments_) => { + const result = originalPushState.apply(history, arguments_); this.debouncedProcess(); return result; }; - history.replaceState = (...stateArguments) => { - const result = originalReplaceState.apply(history, stateArguments); + history.replaceState = (...arguments_) => { + const result = originalReplaceState.apply(history, arguments_); this.debouncedProcess(); return result; }; From a230d9f338cbc8bcda256635ea13a55cbd550069 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Wed, 6 May 2026 10:21:13 -0500 Subject: [PATCH 17/24] fix: wait for conversion before selecting files This should hopefully fix the issue of it sometimes failing to process the link correctly and throwing a "No matching files found after filtering". --- .../magnet-link-to-real-debrid.user.js | 150 +++++++++++------- 1 file changed, 94 insertions(+), 56 deletions(-) diff --git a/userscripts/magnet-link-to-real-debrid.user.js b/userscripts/magnet-link-to-real-debrid.user.js index 981bf10..64e7a01 100644 --- a/userscripts/magnet-link-to-real-debrid.user.js +++ b/userscripts/magnet-link-to-real-debrid.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Magnet Link to Real-Debrid -// @version 2.12.2 +// @version 2.12.3 // @description Automatically send magnet links to Real-Debrid // @author Journey Over // @license MIT @@ -84,8 +84,9 @@ }; }, - async saveConfig(config) { - if (!config || !config.apiKey) throw new ConfigurationError('API Key is required'); + saveConfig(config) { + const errors = this.validateConfig(config); + if (errors.length) throw new ConfigurationError(errors.join('; ')); GM_setValue(STORAGE_KEY, JSON.stringify(config)); }, @@ -111,10 +112,8 @@ static async _reserveRequestSlot() { const key = RealDebridService.RATE_STORE_KEY; const limit = RATE_LIMIT_MAX - RATE_LIMIT_HEADROOM; - const windowMs = RATE_LIMIT_WINDOW_MS; - const maxRetries = RATE_LIMIT_MAX_RETRIES; let attempt = 0; - while (attempt < maxRetries) { + while (attempt < RATE_LIMIT_MAX_RETRIES) { const now = Date.now(); let rateLimitData = null; try { @@ -124,7 +123,7 @@ rateLimitData = null; } - if (!rateLimitData || typeof rateLimitData !== 'object' || !rateLimitData.windowStart || (now - rateLimitData.windowStart) >= windowMs) { + if (!rateLimitData || typeof rateLimitData !== 'object' || !rateLimitData.windowStart || (now - rateLimitData.windowStart) >= RATE_LIMIT_WINDOW_MS) { const fresh = { windowStart: now, count: 1 }; try { GM_setValue(key, JSON.stringify(fresh)); @@ -149,12 +148,12 @@ } const earliest = rateLimitData.windowStart; - const waitFor = Math.max(50, windowMs - (now - earliest) + 50); + const waitFor = Math.max(50, RATE_LIMIT_WINDOW_MS - (now - earliest) + 50); logger.warn(`[Real-Debrid API] Rate limit window full (${rateLimitData.count}/${RATE_LIMIT_MAX}), waiting ${Math.round(waitFor)}ms`); await RealDebridService._sleep(waitFor); attempt += 1; } - throw new Error('Failed to reserve request slot'); + throw new RealDebridError('Failed to reserve request slot'); } constructor(apiKey) { @@ -290,18 +289,17 @@ // Paginate through all torrents to check for existing duplicates async getExistingTorrents() { const torrents = []; - const limit = TORRENTS_PAGE_LIMIT; let pageNumber = 1; while (true) { try { - logger.debug(`[Real-Debrid API] Fetching torrents page ${pageNumber} (limit=${limit})`); - const page = await this.#request('GET', `/torrents?page=${pageNumber}&limit=${limit}`); + logger.debug(`[Real-Debrid API] Fetching torrents page ${pageNumber} (limit=${TORRENTS_PAGE_LIMIT})`); + const page = await this.#request('GET', `/torrents?page=${pageNumber}&limit=${TORRENTS_PAGE_LIMIT}`); if (!Array.isArray(page) || page.length === 0) { logger.warn(`[Real-Debrid API] No torrents returned for page ${pageNumber}`); break; } torrents.push(...page); - if (page.length < limit) { + if (page.length < TORRENTS_PAGE_LIMIT) { logger.debug(`[Real-Debrid API] Last page reached (${pageNumber}) with ${page.length} items`); break; } @@ -400,7 +398,7 @@ announcement.style.cssText = 'position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;'; announcement.textContent = message; document.body.appendChild(announcement); - setTimeout(() => announcement.remove(), 1000); + setTimeout(() => announcement.remove(), 3000); }, createConfigDialog(currentConfig) { @@ -427,7 +425,7 @@ toggleApiButton.addEventListener('click', () => { const isVisible = apiKeyInput.type === 'text'; apiKeyInput.type = isVisible ? 'password' : 'text'; - toggleApiButton.innerHTML = isVisible ? eyeOpenSvg : eyeClosedSvg; + toggleApiButton.innerHTML = isVisible ? eyeClosedSvg : eyeOpenSvg; toggleApiButton.classList.toggle('active', !isVisible); apiKeyInput.focus(); }); @@ -607,7 +605,7 @@ if (node.type === 'folder') { const fileCount = fileTree.getAllFiles(node).length; - div.innerHTML = `
    ${node.expanded?'▼':'▶'}${node.name}${fileCount}
    `; + div.innerHTML = `
    ${node.expanded?'▼':'▶'}${node.name}${fileCount}
    `; if (node.indeterminate) { const checkbox = div.querySelector('.rd-checkbox'); @@ -715,8 +713,8 @@ }, 4000); }, - setIconState(icon, state, torrentSupportEnabled = false) { - const configs = { processing: { opacity: '0.5', cursor: 'wait', title: 'Processing...' }, added: { textContent: '✓', background: '#64cc2e', opacity: '1', cursor: 'default', title: 'Torrent successfully added to Real-Debrid' }, existing: { textContent: '✓', background: '#64cc2e', opacity: '1', cursor: 'not-allowed', title: 'Already in Real-Debrid library' }, default: { textContent: 'RD', background: '#3b82f6', opacity: '1', cursor: 'pointer', title: torrentSupportEnabled ? 'Click to send magnet to Real-Debrid, Alt+click to send torrent file' : 'Click to send magnet to Real-Debrid' } }; + setIconState(icon, state) { + const configs = { processing: { opacity: '0.5', cursor: 'wait', title: 'Processing...' }, added: { textContent: '✓', background: '#64cc2e', opacity: '1', cursor: 'default', title: 'Torrent successfully added to Real-Debrid' }, existing: { textContent: '✓', background: '#64cc2e', opacity: '1', cursor: 'not-allowed', title: 'Already in Real-Debrid library' }, default: { textContent: 'RD', background: '#3b82f6', opacity: '1', cursor: 'pointer', title: 'Click to send magnet to Real-Debrid, Alt+click to send torrent file' } }; icon.style.transition = 'all 0.2s'; const config = configs[state] || configs.default; @@ -728,17 +726,17 @@ if (title) icon.title = title; }, - createMagnetIcon(torrentSupportEnabled = false) { + createMagnetIcon() { const icon = document.createElement('span'); icon.className = 'rd-icon'; icon.textContent = 'RD'; icon.style.cssText = `cursor:pointer;display:inline-block;width:18px;height:18px;margin-left:6px;vertical-align:middle;border-radius:3px;background:#3b82f6;color:white;text-align:center;line-height:18px;font-size:11px;font-weight:bold;font-family:sans-serif;`; icon.setAttribute('data-rd-inserted', '1'); - icon.title = torrentSupportEnabled ? 'Click to send magnet to Real-Debrid, Alt+click to send torrent file' : 'Click to send magnet to Real-Debrid'; + icon.title = 'Click to send magnet to Real-Debrid, Alt+click to send torrent file'; return icon; }, - createMagnetIconWithCheckbox(torrentSupportEnabled = false) { + createMagnetIconWithCheckbox() { const container = document.createElement('span'); container.style.cssText = `display:inline-flex;align-items:center;gap:4px;vertical-align:middle;`; container.setAttribute('data-rd-inserted', '1'); @@ -747,7 +745,7 @@ checkbox.type = 'checkbox'; checkbox.style.cssText = `cursor:pointer;width:14px;height:14px;margin:0;accent-color:#64cc2e;`; - const icon = this.createMagnetIcon(torrentSupportEnabled); + const icon = this.createMagnetIcon(); icon.style.marginLeft = '0'; icon.removeAttribute('data-rd-inserted'); // Remove from icon, keep on container @@ -804,14 +802,14 @@ isTorrentExists(hash) { if (!hash) return false; - return Array.isArray(this.#existingTorrents) && this.#existingTorrents.some(torrent => (torrent.hash || '').toUpperCase() === hash); + return this.#existingTorrents.some(torrent => (torrent.hash || '').toUpperCase() === hash); } filterFiles(files = []) { const allowed = new Set(this.#config.allowedExtensions.map(extension => extension.trim().toLowerCase()).filter(Boolean)); const keywords = (this.#config.filterKeywords || []).map(keyword => keyword.trim()).filter(Boolean); - return (files || []).filter(file => { + return files.filter(file => { const path = (file.path || '').toLowerCase(); const name = path.split('/').pop() || ''; const extension = name.includes('.') ? name.split('.').pop() : ''; @@ -819,7 +817,6 @@ if (!allowed.has(extension)) return false; for (const keyword of keywords) { - if (!keyword) continue; // Handle regex patterns (format: /pattern/) if (keyword.startsWith('/') && keyword.endsWith('/')) { try { @@ -835,6 +832,45 @@ }); } + async _waitForTorrentFiles(torrentId) { + const MAX_TOTAL_TIME_MS = 75 * 1000; + const MAX_FETCHES = 15; + const delays = [500, 1500, 3000, 5000]; + + const startTime = Date.now(); + let attempt = 0; + let lastStatus = null; + + while (attempt < MAX_FETCHES) { + if (Date.now() - startTime >= MAX_TOTAL_TIME_MS) { + await this.#realDebridApi.deleteTorrent(torrentId); + throw new RealDebridError(`Timed out waiting for torrent to become ready (last status: ${lastStatus})`); + } + + logger.debug(`[Magnet Processor] Polling torrent ${torrentId} for files (attempt ${attempt + 1}/${MAX_FETCHES}, last status: ${lastStatus || 'none'})`); + const info = await this.#realDebridApi.getTorrentInfo(torrentId); + lastStatus = info.status; + + if (info.status === 'error' || info.status === 'dead' || info.status === 'virus') { + await this.#realDebridApi.deleteTorrent(torrentId); + throw new RealDebridError(`Torrent failed: ${info.status}`); + } + + if (Array.isArray(info.files) && info.files.length > 0) { + logger.debug(`[Magnet Processor] Torrent ${torrentId} files are available (status: ${info.status})`); + return info; + } + + const delay = attempt < delays.length ? delays[attempt] : (5000 + Math.random() * 250); + logger.debug(`[Magnet Processor] Torrent ${torrentId} still waiting for files (status: ${info.status}); retrying in ${delay}ms`); + await RealDebridService._sleep(delay); + attempt++; + } + + await this.#realDebridApi.deleteTorrent(torrentId); + throw new RealDebridError(`Timed out waiting for torrent to become ready (last status: ${lastStatus})`); + } + async _selectFiles(torrentId, files) { let selectedFileIds; if (this.#config.manualFileSelection) { @@ -859,7 +895,7 @@ if (this.isTorrentExists(hash)) throw new RealDebridError('Torrent already exists on Real-Debrid'); const addResult = await this.#realDebridApi.addMagnet(magnetLink); if (!addResult || typeof addResult.id === 'undefined') throw new RealDebridError(`Failed to add magnet: ${JSON.stringify(addResult)}`); - const info = await this.#realDebridApi.getTorrentInfo(addResult.id); + const info = await this._waitForTorrentFiles(addResult.id); return this._selectFiles(addResult.id, Array.isArray(info.files) ? info.files : []); } @@ -867,7 +903,7 @@ const torrentBlob = await this.fetchTorrentFile(torrentUrl); const addResult = await this.#realDebridApi.addTorrent(torrentBlob); if (!addResult || typeof addResult.id === 'undefined') throw new RealDebridError('Failed to add torrent'); - const info = await this.#realDebridApi.getTorrentInfo(addResult.id); + const info = await this._waitForTorrentFiles(addResult.id); return this._selectFiles(addResult.id, Array.isArray(info.files) ? info.files : []); } @@ -975,19 +1011,19 @@ UIManager.showToast(`Processing ${index + 1}/${selectedUrls.length} links...`, 'info'); for (const icon of icons) { - UIManager.setIconState(icon, 'processing', true); + UIManager.setIconState(icon, 'processing'); } try { await this.processor.processMagnetLink(url); successCount++; for (const icon of icons) { - UIManager.setIconState(icon, 'added', true); + UIManager.setIconState(icon, 'added'); } } catch (error) { errorCount++; for (const icon of icons) { - UIManager.setIconState(icon, 'default', true); + UIManager.setIconState(icon, 'default'); } logger.error(`[Batch Processing] Failed to process ${url}`, error); } @@ -1002,9 +1038,9 @@ } _magnetKeyFor(href) { + if (!href) return 'href:'; const hash = MagnetLinkProcessor.parseMagnetHash(href); - if (hash) return `hash:${hash}`; - try { return `href:${href.trim().toLowerCase()}`; } catch { return `href:${String(href).trim().toLowerCase()}`; } + return hash ? `hash:${hash}` : `href:${String(href).trim().toLowerCase()}`; } _storeIconForKey(key, iconContainer) { @@ -1051,39 +1087,39 @@ if (isProcessingMagnet && key?.startsWith('hash:') && this.processor?.isTorrentExists(key.split(':')[1])) { UIManager.showToast('Torrent already exists on Real-Debrid', 'info'); - UIManager.setIconState(icon, 'existing', true); + UIManager.setIconState(icon, 'existing'); return; } - UIManager.setIconState(icon, 'processing', true); + UIManager.setIconState(icon, 'processing'); try { const fileCount = isProcessingMagnet ? await this.processor.processMagnetLink(linkToProcess.href) : await this.processor.processTorrentLink(linkToProcess.href); UIManager.showToast(`Added to Real-Debrid - ${fileCount} file(s) selected`, 'success'); - UIManager.setIconState(icon, 'added', true); + UIManager.setIconState(icon, 'added'); } catch (error) { - UIManager.setIconState(icon, 'default', true); + UIManager.setIconState(icon, 'default'); UIManager.showToast(error?.message || 'Failed to process link', 'error'); logger.error('[Link Processor] Failed to process link', error); } }; - icon.addEventListener('click', (event_) => { - event_.preventDefault(); - processLink(event_); + icon.addEventListener('click', (event) => { + event.preventDefault(); + processLink(event); }); if (checkbox) { - checkbox.addEventListener('change', (event_) => { - event_.stopPropagation(); + checkbox.addEventListener('change', (event) => { + event.stopPropagation(); if (icon.textContent === '✓') return; if (checkbox.checked) this.selectedLinks.add(link.href); else this.selectedLinks.delete(link.href); this._updateBatchButton(); }); - checkbox.addEventListener('click', (event_) => { event_.stopPropagation(); }); + checkbox.addEventListener('click', (event) => { event.stopPropagation(); }); } } @@ -1098,7 +1134,7 @@ } this.initialMagnetLinkCount = uniqueHashes.size; } - const newlyAddedKeys = []; + let hasNewIcons = false; for (const link of links) { if (!link.parentNode) continue; @@ -1112,18 +1148,17 @@ const key = this._magnetKeyFor(link.href); const iconContainer = this._shouldShowBatchUI() ? - UIManager.createMagnetIconWithCheckbox(true) : - UIManager.createMagnetIcon(true); + UIManager.createMagnetIconWithCheckbox() : + UIManager.createMagnetIcon(); this._attach(iconContainer, link); link.parentNode.insertBefore(iconContainer, link.nextSibling); link.setAttribute('data-rd-processed', '1'); - const storeKey = key || `href:${link.href.trim().toLowerCase()}`; - this._storeIconForKey(storeKey, iconContainer); - newlyAddedKeys.push(storeKey); + this._storeIconForKey(key, iconContainer); + hasNewIcons = true; } - if (newlyAddedKeys.length) { + if (hasNewIcons) { ensureApiInitialized().then(isInitialized => { if (isInitialized) this.markExistingTorrents(); }); @@ -1139,7 +1174,7 @@ if (this.processor.isTorrentExists(hash)) { for (const iconContainer of iconContainers) { const icon = iconContainer.querySelector('.rd-icon') || iconContainer; - UIManager.setIconState(icon, 'existing', true); + UIManager.setIconState(icon, 'existing'); } } } @@ -1183,7 +1218,6 @@ // Lazy initialization to avoid API calls until first magnet link is clicked let _apiInitPromise = null; - let _realDebridService = null; let _magnetProcessor = null; let _integratorInstance = null; @@ -1191,21 +1225,22 @@ if (_apiInitPromise) return _apiInitPromise; try { - if (!document.querySelector || !document.querySelector('a[href^="magnet:"]')) return false; - } catch {} + if (!document.querySelector('a[href^="magnet:"]')) return false; + } catch { + return false; + } const config = ConfigManager.getConfigSync(); if (!config.apiKey) return false; try { - _realDebridService = new RealDebridService(config.apiKey); + _magnetProcessor = new MagnetLinkProcessor(config, new RealDebridService(config.apiKey)); } catch (error) { logger.warn('[Initialization] Failed to create Real-Debrid service', error); return false; } - _magnetProcessor = new MagnetLinkProcessor(config, _realDebridService); - _apiInitPromise = _magnetProcessor.initialize() + const initPromise = _magnetProcessor.initialize() .then(() => { if (_integratorInstance) { _integratorInstance.setProcessor(_magnetProcessor); @@ -1215,9 +1250,12 @@ }) .catch(error => { logger.warn('[Initialization] Failed to initialize Real-Debrid integration', error); + _apiInitPromise = null; return false; }); + _apiInitPromise = initPromise; + return _apiInitPromise; } From 9e06fd6f7669f715f8b8e8a562db679750ba645e Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 17 May 2026 12:36:41 -0500 Subject: [PATCH 18/24] fix: year extraction now works correctly.. --- userscripts/mediux-yaml-fixes.user.js | 33 +++++++++++---------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/userscripts/mediux-yaml-fixes.user.js b/userscripts/mediux-yaml-fixes.user.js index 28f4e19..8f78343 100644 --- a/userscripts/mediux-yaml-fixes.user.js +++ b/userscripts/mediux-yaml-fixes.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Mediux - Yaml Fixes -// @version 2.2.4 +// @version 2.2.5 // @description Adds fixes and functions to Mediux // @author Journey Over // @license MIT @@ -47,22 +47,15 @@ ); }, - // Extract year from page elements, trying multiple selectors for reliability - getYear() { - const selectors = [ - 'h1', - 'a[href*="/sets/"]', - 'a[href*="/shows/"]' - ]; - - for (const selector of selectors) { - const element = document.querySelector(selector); - if (element) { - const match = element.textContent.match(/\((\d{4})\)/); - if (match) return match[1]; - } - } - return 'Unknown'; + // Extract year from the correct set's page element using its set ID. + // On listing pages, find the link for that specific set. + // On single set pages, fall back to the page heading (no self-link exists). + getYear(setId) { + const setLink = document.querySelector(`a[href="/sets/${setId}"]`); + const heading = document.querySelector('h1'); + const text = setLink?.textContent ?? heading?.textContent ?? ''; + const yearMatch = text.match(/\((\d{4})\)/); + return yearMatch ? yearMatch[1] : 'Unknown'; }, showNotification(message, duration = 3000) { @@ -333,15 +326,15 @@ const button = document.querySelector('#fytvbutton'); let yamlContent = codeblock.textContent; - const regexSetInfo = /(null|\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/\d+)/; - - const year = MediuxFixes.utils.getYear(); + const regexSetInfo = /(null|\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/(\d+))/; const setMatch = yamlContent.match(regexSetInfo); if (setMatch) { const tvdbId = setMatch[1]; const showTitle = setMatch[2]; const setUrl = setMatch[4]; + const setId = setMatch[5]; + const year = MediuxFixes.utils.getYear(setId); yamlContent = yamlContent.replace(regexSetInfo, `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n ${tvdbId}: # ${showTitle} (${year})`); } From 7bb705983aadfc912d08cee6e2c0e43e41e542c4 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Sun, 17 May 2026 14:06:23 -0500 Subject: [PATCH 19/24] refactor: remove mediux yaml fixes in favor of just a yaml to kometa convertor I may re-include more things from yaml fixes at some point but currently unsure tbh.. --- README.md | 4 +- userscripts/mediux-yaml-fixes.user.js | 509 ---------------------- userscripts/mediux-yaml-to-kometa.user.js | 217 +++++++++ 3 files changed, 219 insertions(+), 511 deletions(-) delete mode 100644 userscripts/mediux-yaml-fixes.user.js create mode 100644 userscripts/mediux-yaml-to-kometa.user.js diff --git a/README.md b/README.md index 1073731..256e0f9 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ This table shows supported browsers and their compatible userscript managers. Cl | GitHub - Latest | Always keep an eye on the latest activity of your favorite projects | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/github-latest.user.js) | | Hy-Vee - Auto Clip Coupons | Add a button to manually clip all coupons on the Hy-Vee coupons page. | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/hyvee-auto-click-coupons.user.js) | | Magnet Link to Real-Debrid | Automatically send magnet links to Real-Debrid | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/magnet-link-to-real-debrid.user.js) | -| Mediux - Yaml Fixes | Adds fixes and functions to Mediux | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-yaml-fixes.user.js) | -| Mediux - Auto-fill description | Auto-fills the description field on Mediux with year/type info | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-autofill-description.user.js) | +| Mediux - YAML to Kometa | Adds buttons to transform MediUX TV and movie YAML into Kometa-compatible metadata. | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-yaml-to-kometa.user.js) | +| Mediux - Auto-fill description | Adds a button to auto-fill the description field with attribution text | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/mediux-autofill-description.user.js) | | MyAnimeList - Add Trakt link | Add trakt link to MyAnimeList anime pages | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/myanimelist-add-trakt-link.user.js) | | AniList - Add Trakt link | Add trakt link to AniList anime pages | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/anilist-add-trakt-link.user.js) | | Nexus Mod - Updated Mod Highlighter | Highlight mods that have updated since you last downloaded them | [Install](https://raw.githubusercontent.com/StylusThemes/Userscripts/main/userscripts/nexusmods-updated-mod-highlighter.user.js) | diff --git a/userscripts/mediux-yaml-fixes.user.js b/userscripts/mediux-yaml-fixes.user.js deleted file mode 100644 index 8f78343..0000000 --- a/userscripts/mediux-yaml-fixes.user.js +++ /dev/null @@ -1,509 +0,0 @@ -// ==UserScript== -// @name Mediux - Yaml Fixes -// @version 2.2.5 -// @description Adds fixes and functions to Mediux -// @author Journey Over -// @license MIT -// @match *://mediux.pro/* -// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/libs/utils/utils.min.js -// @require https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js -// @grant GM_xmlhttpRequest -// @grant GM_setValue -// @grant GM_getValue -// @run-at document-end -// @icon https://www.google.com/s2/favicons?sz=64&domain=mediux.pro -// @homepageURL https://github.com/StylusThemes/Userscripts -// @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/mediux-yaml-fixes.user.js -// @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/mediux-yaml-fixes.user.js -// ==/UserScript== - -(function() { - 'use strict'; - - const logger = Logger('Mediux - Yaml Fixes', { debug: false }); - - const MediuxFixes = { - elements: { - codeblock: null, - buttons: {} - }, - - utils: { - sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - }, - - isString(input) { - return typeof input === 'string'; - }, - - // Check for valid objects with properties (excludes null, arrays, empty objects) - isNonEmptyObject(object) { - return ( - typeof object === 'object' && - object !== null && - !Array.isArray(object) && - Object.keys(object).length > 0 - ); - }, - - // Extract year from the correct set's page element using its set ID. - // On listing pages, find the link for that specific set. - // On single set pages, fall back to the page heading (no self-link exists). - getYear(setId) { - const setLink = document.querySelector(`a[href="/sets/${setId}"]`); - const heading = document.querySelector('h1'); - const text = setLink?.textContent ?? heading?.textContent ?? ''; - const yearMatch = text.match(/\((\d{4})\)/); - return yearMatch ? yearMatch[1] : 'Unknown'; - }, - - showNotification(message, duration = 3000) { - const notification = document.createElement('div'); - const myleftDiv = document.querySelector('#myleftdiv'); - - Object.assign(notification.style, { - width: '50%', - height: '50%', - backgroundColor: 'rgba(200, 200, 200, 0.85)', - color: 'black', - padding: '20px', - borderRadius: '5px', - justifyContent: 'center', - alignItems: 'center', - zIndex: '1000', - display: 'flex' - }); - - notification.innerText = message; - $(myleftDiv).after(notification); - - setTimeout(() => { - $(notification).remove(); - }, duration); - }, - - updateButtonState(button, success = true) { - button.classList.remove('bg-gray-500'); - button.classList.add(success ? 'bg-green-500' : 'bg-red-500'); - - setTimeout(() => { - button.classList.remove('bg-green-500', 'bg-red-500'); - button.classList.add('bg-gray-500'); - }, 3000); - }, - - copyToClipboard(text) { - return navigator.clipboard.writeText(text) - .then(() => { - this.showNotification('Results copied to clipboard!'); - return true; - }) - .catch(error => { - logger.error('Failed to copy: ', error); - this.showNotification('Failed to copy to clipboard', 3000); - return false; - }); - } - }, - - data: { - // Extract poster data from Next.js script tags by searching from end (newer scripts tend to be last) - getPosters() { - const regexPosterCheck = /posterCheck/g; - const scriptElements = document.querySelectorAll('script'); - - for (let index = scriptElements.length - 1; index >= 0; index--) { - const element = scriptElements[index]; - if (regexPosterCheck.test(element.textContent)) { - let scriptContent = element.textContent.replace('self.__next_f.push(', ''); - scriptContent = scriptContent.substring(0, scriptContent.length - 1); - const jsonData = JSON.parse(scriptContent)[1].split('{"set":')[1]; - const fullJsonString = `{"set":${jsonData}`; - const parsedData = JSON.parse(fullJsonString.substring(0, fullJsonString.length - 2)); - return parsedData.set.files; - } - } - return []; - }, - - // Extract set data and store creator info for later use - getSets() { - const regexPosterCheck = /posterCheck/g; - const scriptElements = document.querySelectorAll('script'); - - for (let index = scriptElements.length - 1; index >= 0; index--) { - const element = scriptElements[index]; - if (regexPosterCheck.test(element.textContent)) { - let scriptContent = element.textContent.replace('self.__next_f.push(', ''); - scriptContent = scriptContent.substring(0, scriptContent.length - 1); - const jsonData = JSON.parse(scriptContent)[1].split('{"set":')[1]; - const fullJsonString = `{"set":${jsonData}`; - const parsedData = JSON.parse(fullJsonString.substring(0, fullJsonString.length - 2)); - GM_setValue('creator', parsedData.set.user_created.username); - return parsedData.set.boxset.sets; - } - } - return []; - }, - - getSet(setId) { - return new Promise((resolve, reject) => { - GM_xmlhttpRequest({ - method: 'GET', - url: `https://mediux.pro/sets/${setId}`, - timeout: 30000, - onload: (response) => { - resolve(response.responseText); - }, - onerror: () => { - logger.error(`An error occurred loading set ${setId}`); - reject(new Error('Request failed')); - }, - ontimeout: () => { - logger.error(`It took too long to load set ${setId}`); - reject(new Error('Request timed out')); - } - }); - }); - }, - - parseFilesFromResponse(response) { - const responseWithoutEscapes = response.replaceAll('\\', ''); - const regexFiles = /"files":(\[{"id":.*?}]),"boxset":/s; - const fileMatch = responseWithoutEscapes.match(regexFiles); - - if (!fileMatch || !fileMatch[1]) return null; - - try { - const files = JSON.parse(fileMatch[1]); - return files - .filter(file => !file.title.trim().endsWith('Collection')) - .sort((fileA, fileB) => fileA.title.localeCompare(fileB.title)); - } catch (error) { - logger.error('Error parsing filesArray:', error); - return null; - } - } - }, - - yaml: { - _generateFileYaml(file, creator, setId) { - if (file.movie_id !== null) { - const posterId = file.fileType === 'poster' && file.id.length > 0 ? file.id : 'N/A'; - const movieId = MediuxFixes.utils.isNonEmptyObject(file.movie_id) ? file.movie_id.id : 'N/A'; - const movieTitle = MediuxFixes.utils.isString(file.title) && file.title.length > 0 ? file.title.trimEnd() : 'N/A'; - const yaml = ` ${movieId}: # ${movieTitle} Poster by ${creator} on MediUX. https://mediux.pro/sets/${setId}\n url_poster: https://api.mediux.pro/assets/${posterId}\n `; - logger(`Title: ${movieTitle}\nPoster: ${posterId}`); - return { yaml, title: movieTitle }; - } - - if (file.movie_id_backdrop !== null) { - const backdropId = file.fileType === 'backdrop' && file.id.length > 0 ? file.id : 'N/A'; - const movieId = MediuxFixes.utils.isNonEmptyObject(file.movie_id_backdrop) ? file.movie_id_backdrop.id : 'N/A'; - logger(`Backdrop: ${backdropId}\nMovie id: ${movieId}`); - return { yaml: `url_background: https://api.mediux.pro/assets/${backdropId}\n\n`, title: null }; - } - - return { yaml: '', title: null }; - }, - - _showCompletionLink(codeblock, button, yamlOutput) { - codeblock.innerText = 'Processing complete!'; - const copyLink = document.createElement('a'); - copyLink.href = '#'; - copyLink.innerText = 'Click here to copy the results'; - copyLink.style.color = 'blue'; - copyLink.style.cursor = 'pointer'; - - copyLink.addEventListener('click', async (event_) => { - event_.preventDefault(); - try { - await navigator.clipboard.writeText(yamlOutput); - codeblock.innerText = yamlOutput; - MediuxFixes.utils.updateButtonState(button); - MediuxFixes.utils.showNotification('Results copied to clipboard!'); - } catch (error) { - logger.error('Failed to copy: ', error); - } - }); - - codeblock.appendChild(copyLink); - }, - - async loadBoxset(codeblock) { - const button = document.querySelector('#bsetbutton'); - let yamlOutput = codeblock.textContent + '\n'; - const sets = MediuxFixes.data.getSets(); - const creator = GM_getValue('creator'); - const startTime = Date.now(); - const processedMovieTitles = []; - - codeblock.innerText = 'Processing... 0 seconds'; - - const timerInterval = setInterval(() => { - const elapsedTime = Math.floor((Date.now() - startTime) / 1000); - const latestMovies = processedMovieTitles.slice(-3).join(', '); - codeblock.innerText = `Processing... ${elapsedTime} seconds\nRecent processed: ${latestMovies}`; - }, 1000); - - try { - for (const set of sets) { - try { - const response = await MediuxFixes.data.getSet(set.id); - const files = MediuxFixes.data.parseFilesFromResponse(response); - if (!files) continue; - - for (const file of files) { - const { yaml, title } = MediuxFixes.yaml._generateFileYaml(file, creator, set.id); - yamlOutput += yaml; - if (title) processedMovieTitles.push(title); - } - } catch (error) { - logger.error(`Error processing set ${set.id}:`, error); - } - } - } finally { - clearInterval(timerInterval); - } - - MediuxFixes.yaml._showCompletionLink(codeblock, button, yamlOutput); - const totalTime = Math.floor((Date.now() - startTime) / 1000); - logger(`Total time taken: ${totalTime} seconds`); - }, - - // Add missing season posters to YAML (seasons without explicit poster entries) - fixPosters(codeblock) { - const button = document.querySelector('#fpbutton'); - let yamlContent = codeblock.textContent; - const posters = MediuxFixes.data.getPosters(); - - const seasonPosters = posters.filter(poster => poster.title.includes('Season')); - - for (const seasonIndex in seasonPosters) { - const matchingSeasonPosters = seasonPosters.filter(season => season.title.includes(`Season ${seasonIndex}`)); - if (matchingSeasonPosters.length > 0) { - yamlContent += ` ${seasonIndex}:\n url_poster: https://api.mediux.pro/assets/${matchingSeasonPosters[0].id}\n`; - } - } - - codeblock.innerText = yamlContent; - navigator.clipboard.writeText(yamlContent) - .then(() => { - MediuxFixes.utils.showNotification('Results copied to clipboard!'); - MediuxFixes.utils.updateButtonState(button); - }); - }, - - // Fix Kometa TitleCard YAML by adding missing season numbers before episodes - fixCards(codeblock) { - const button = document.querySelector('#fcbutton'); - const yamlContent = codeblock.innerText; - - const regexSeasonsEpisodes = /(seasons:\n)( episodes:)/g; - const regexEpisodes = /( episodes:)/g; - - if (regexSeasonsEpisodes.test(yamlContent)) { - let seasonCounter = 1; - const modifiedYaml = yamlContent.replace(regexEpisodes, (match) => { - const newLine = ` ${seasonCounter++}:\n`; - return `${newLine}${match}`; - }); - - codeblock.innerText = modifiedYaml; - navigator.clipboard.writeText(modifiedYaml) - .then(() => { - MediuxFixes.utils.showNotification('Results copied to clipboard!'); - MediuxFixes.utils.updateButtonState(button); - }); - } else { - MediuxFixes.utils.showNotification('No card formatting needed'); - } - }, - - // Transform TV show YAML to Kometa-compatible format with proper metadata structure - formatTvYml(codeblock) { - const button = document.querySelector('#fytvbutton'); - let yamlContent = codeblock.textContent; - - const regexSetInfo = /(null|\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/(\d+))/; - - const setMatch = yamlContent.match(regexSetInfo); - if (setMatch) { - const tvdbId = setMatch[1]; - const showTitle = setMatch[2]; - const setUrl = setMatch[4]; - const setId = setMatch[5]; - const year = MediuxFixes.utils.getYear(setId); - - yamlContent = yamlContent.replace(regexSetInfo, `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n ${tvdbId}: # ${showTitle} (${year})`); - } - - yamlContent = yamlContent.replace(/^\s+# Posters from:/m, `# Posters from:`); - yamlContent = yamlContent.replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)/g, '$1: "$2"'); - yamlContent = yamlContent.replace(/(\d+):\n\s+url_poster: (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)\n/g, - (match, season, posterUrl) => ` ${season}:\n url_poster: "${posterUrl}"\n`); - - codeblock.innerText = yamlContent; - navigator.clipboard.writeText(yamlContent) - .then(() => { - MediuxFixes.utils.showNotification('YAML transformed and copied to clipboard!'); - MediuxFixes.utils.updateButtonState(button); - }); - }, - - // Transform movie YAML to Kometa format with standardized headers and URL quoting - formatMovieYml(codeblock) { - const button = document.querySelector('#fymoviebutton'); - let yamlContent = codeblock.textContent; - - const regexSetUrl = /https:\/\/mediux\.pro\/sets\/\d+/; - const urlMatch = yamlContent.match(regexSetUrl); - const setUrl = urlMatch ? urlMatch[0] : null; - - if (setUrl) { - yamlContent = yamlContent.replace( - /(\d+):\s*#\s*(.*?)\s*\((\d{4})\).*?(https:\/\/mediux\.pro\/sets\/\d+)/g, - (match, movieId, movieTitle, releaseYear) => `${movieId}: # ${movieTitle.trim()} (${releaseYear})` - ); - - const yamlHeader = `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n`; - yamlContent = yamlContent.replace(/(^|\n)metadata:\n/g, ''); - yamlContent = yamlHeader + yamlContent; - - yamlContent = yamlContent - .replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/\S+)/g, '$1: "$2"') - .replace(/(\n\n)(\s+\n)/g, '\n\n') - .replace(/\n{3,}/g, '\n\n'); - } - - codeblock.innerText = yamlContent; - navigator.clipboard.writeText(yamlContent) - .then(() => { - MediuxFixes.utils.showNotification('YAML transformed and copied to clipboard!'); - MediuxFixes.utils.updateButtonState(button); - }); - } - }, - - ui: { - createInterface() { - const codeblock = document.querySelector('code.whitespace-pre-wrap'); - MediuxFixes.elements.codeblock = codeblock; - - // Restructure page layout to make space for custom buttons - const mainContainerDiv = document.querySelector('.flex.flex-col.space-y-1\\.5.text-center.sm\\:text-left'); - $(mainContainerDiv).children('h2, p').wrapAll('
    '); - - const leftContainerDiv = document.querySelector('#myleftdiv'); - - const buttonConfigs = [{ - id: 'fcbutton', - title: 'Fix missing season numbers in TitleCard YAML', - icon: '', - action: () => MediuxFixes.yaml.fixCards(codeblock) - }, - { - id: 'fpbutton', - title: 'Fix missing season posters YAML', - icon: '', - action: () => MediuxFixes.yaml.fixPosters(codeblock) - }, - { - id: 'bsetbutton', - title: 'Generate YAML for associated boxset', - icon: '', - action: () => MediuxFixes.yaml.loadBoxset(codeblock) - }, - { - id: 'fytvbutton', - title: 'Format TV show YAML for Kometa', - icon: '', - action: () => MediuxFixes.yaml.formatTvYml(codeblock) - }, - { - id: 'fymoviebutton', - title: 'Format Movie YAML for Kometa', - icon: '', - action: () => MediuxFixes.yaml.formatMovieYml(codeblock) - } - ]; - - const buttonContainer = $('
    '); - - for (const [index, config] of buttonConfigs.entries()) { - const $buttonElement = $(``); - $buttonElement.on('click', config.action); - buttonContainer.append($buttonElement); - MediuxFixes.elements.buttons[config.id] = $buttonElement[0]; - } - - $(leftContainerDiv).append(buttonContainer); - $(leftContainerDiv).parent().append('
    '); - } - }, - - init() { - waitForKeyElements('code.whitespace-pre-wrap', () => { - this.ui.createInterface(); - logger('Initialized'); - }); - } - }; - - MediuxFixes.init(); - - // Utility function to wait for dynamically loaded elements in Next.js SPA - function waitForKeyElements( - selectorTxt, - actionFunction, - bWaitOnce, - iframeSelector - ) { - const targetElements = typeof iframeSelector == 'undefined' ? jQuery(selectorTxt) : jQuery(iframeSelector).contents() - .find(selectorTxt); - let targetsFound; - - if (targetElements && targetElements.length > 0) { - targetsFound = true; - targetElements.each(function() { - const $currentElement = jQuery(this); - const alreadyProcessed = $currentElement.data('alreadyFound') || false; - - if (!alreadyProcessed) { - const shouldCancel = actionFunction($currentElement); - if (shouldCancel) - targetsFound = false; - else - $currentElement.data('alreadyFound', true); - } - }); - } else { - targetsFound = false; - } - - const controlObject = waitForKeyElements.controlObj || {}; - const controlKey = selectorTxt.replace(/[^\w]/g, '_'); - let intervalId = controlObject[controlKey]; - - if (targetsFound && bWaitOnce && intervalId) { - clearInterval(intervalId); - delete controlObject[controlKey] - } else { - if (!intervalId) { - intervalId = setInterval(function() { - waitForKeyElements(selectorTxt, - actionFunction, - bWaitOnce, - iframeSelector - ); - }, - 300 - ); - controlObject[controlKey] = intervalId; - } - } - waitForKeyElements.controlObj = controlObject; - } - -})(); diff --git a/userscripts/mediux-yaml-to-kometa.user.js b/userscripts/mediux-yaml-to-kometa.user.js new file mode 100644 index 0000000..840de4b --- /dev/null +++ b/userscripts/mediux-yaml-to-kometa.user.js @@ -0,0 +1,217 @@ +// ==UserScript== +// @name Mediux - YAML to Kometa +// @version 2.3.0 +// @description Adds buttons to transform MediUX TV and movie YAML into Kometa-compatible metadata. +// @author Journey Over +// @license MIT +// @match *://mediux.pro/* +// @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/libs/utils/utils.min.js +// @grant none +// @run-at document-end +// @icon https://www.google.com/s2/favicons?sz=64&domain=mediux.pro +// @homepageURL https://github.com/StylusThemes/Userscripts +// @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/mediux-yaml-to-kometa.user.js +// @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/mediux-yaml-to-kometa.user.js +// ==/UserScript== + +(function() { + 'use strict'; + + const logger = Logger('Mediux - YAML to Kometa', { debug: false }); + + const App = { + utils: { + getYear(setId) { + const setLinkText = document.querySelector(`a[href="/sets/${setId}"]`)?.textContent?.trim() || ''; + const headingText = document.querySelector('h1')?.textContent?.trim() || ''; + const headerYearText = document.querySelector('header p:first-of-type')?.textContent?.trim() || ''; + + return setLinkText.match(/\((\d{4})\)/)?.[1] || + headingText.match(/\((\d{4})\)/)?.[1] || + headerYearText.match(/^(\d{4})$/)?.[1] || + 'Unknown'; + }, + + showNotification(message, targetButton, duration = 3000) { + const tooltip = document.createElement('div'); + tooltip.textContent = message; + Object.assign(tooltip.style, { + position: 'fixed', + bottom: (window.innerHeight - targetButton.getBoundingClientRect().top + 6) + 'px', + left: (targetButton.getBoundingClientRect().left + targetButton.offsetWidth / 2) + 'px', + transform: 'translateX(-50%)', + background: '#1f2937', + color: '#f3f4f6', + padding: '4px 10px', + borderRadius: '6px', + fontSize: '11px', + lineHeight: '1.4', + whiteSpace: 'nowrap', + zIndex: '999', + pointerEvents: 'none', + boxShadow: '0 4px 6px -1px rgba(0,0,0,0.3)' + }); + + document.body.appendChild(tooltip); + setTimeout(() => tooltip.remove(), duration); + }, + + updateButtonState(button, success = true) { + const successClass = success ? 'text-green-500' : 'text-red-500'; + button.classList.remove('text-gray-400'); + button.classList.add(successClass); + + setTimeout(() => { + button.classList.remove('text-green-500', 'text-red-500'); + button.classList.add('text-gray-400'); + }, 3000); + } + }, + + yaml: { + formatTvYml(codeblock, button) { + let yamlContent = codeblock.textContent; + + const regexSetInfo = /(null|\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/(\d+))/; + + const setMatch = yamlContent.match(regexSetInfo); + if (setMatch) { + const tvdbId = setMatch[1]; + const showTitle = setMatch[2]; + const setUrl = setMatch[4]; + const setId = setMatch[5]; + const year = App.utils.getYear(setId); + + yamlContent = yamlContent.replace(regexSetInfo, `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n ${tvdbId}: # ${showTitle} (${year})`); + } + + yamlContent = yamlContent.replace(/^\s+# Posters from:/m, `# Posters from:`); + yamlContent = yamlContent.replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)/g, '$1: "$2"'); + yamlContent = yamlContent.replace(/(\d+):\n\s+url_poster: (https:\/\/api\.mediux\.pro\/assets\/[a-z0-9\-]+)\n/g, + (match, season, posterUrl) => ` ${season}:\n url_poster: "${posterUrl}"\n`); + + codeblock.innerText = yamlContent; + navigator.clipboard.writeText(yamlContent) + .then(() => { + App.utils.showNotification('YAML transformed and copied to clipboard!', button); + App.utils.updateButtonState(button); + }) + .catch(error => { + logger.error('Clipboard write failed', error); + App.utils.updateButtonState(button, false); + }); + }, + + formatMovieYml(codeblock, button) { + let yamlContent = codeblock.textContent; + + const regexSetUrl = /https:\/\/mediux\.pro\/sets\/\d+/; + const urlMatch = yamlContent.match(regexSetUrl); + const setUrl = urlMatch ? urlMatch[0] : null; + + if (setUrl) { + yamlContent = yamlContent.replace( + /(\d+):\s*#\s*(.*?)\s*\((\d{4})\).*?(https:\/\/mediux\.pro\/sets\/\d+)/g, + (match, movieId, movieTitle, releaseYear) => `${movieId}: # ${movieTitle.trim()} (${releaseYear})` + ); + + const yamlHeader = `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n`; + yamlContent = yamlContent.replace(/(^|\n)metadata:\n/g, ''); + yamlContent = yamlHeader + yamlContent; + + yamlContent = yamlContent + .replace(/(url_poster|url_background): (https:\/\/api\.mediux\.pro\/assets\/\S+)/g, '$1: "$2"') + .replace(/(\n\n)(\s+\n)/g, '\n\n') + .replace(/\n{3,}/g, '\n\n'); + } + + codeblock.innerText = yamlContent; + navigator.clipboard.writeText(yamlContent) + .then(() => { + App.utils.showNotification('YAML transformed and copied to clipboard!', button); + App.utils.updateButtonState(button); + }) + .catch(error => { + logger.error('Clipboard write failed', error); + App.utils.updateButtonState(button, false); + }); + } + }, + + ui: { + createInterface(codeblock) { + if (!codeblock) return; + + const dialog = codeblock.closest('[role="dialog"]'); + if (!dialog) return; + + if (dialog.querySelector('#extbuttons')) return; + + const buttonConfigs = [ + { + id: 'fytvbutton', + title: 'Copy TV YAML to clipboard', + icon: '', + text: 'TV', + action: (button) => App.yaml.formatTvYml(codeblock, button) + }, + { + id: 'fymoviebutton', + title: 'Copy Movie YAML to clipboard', + icon: '', + text: 'Movie', + action: (button) => App.yaml.formatMovieYml(codeblock, button) + } + ]; + + const extensionButtons = document.createElement('div'); + extensionButtons.id = 'extbuttons'; + extensionButtons.className = 'flex flex-wrap gap-2'; + extensionButtons.setAttribute('role', 'group'); + extensionButtons.setAttribute('aria-label', 'YAML actions'); + + for (const config of buttonConfigs) { + const button = document.createElement('button'); + button.id = config.id; + button.type = 'button'; + button.title = config.title; + button.className = 'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-400 hover:text-white border border-gray-700 hover:border-gray-500 transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-gray-500'; + button.innerHTML = config.icon + '' + config.text + ''; + button.addEventListener('click', () => config.action(button)); + extensionButtons.appendChild(button); + } + + // Find the dialog's direct child that contains the code block + const codeBlockContainer = [...dialog.children].find(child => child.contains(codeblock)); + if (codeBlockContainer) { + codeBlockContainer.before(extensionButtons); + } else { + dialog.appendChild(extensionButtons); + } + } + }, + + init() { + observeElement('code.whitespace-pre-wrap', (codeblock) => { + this.ui.createInterface(codeblock); + logger('Initialized'); + }); + } + }; + + App.init(); + + function observeElement(selector, callback) { + const existing = document.querySelector(selector); + if (existing) { callback(existing); return; } + + const observer = new MutationObserver(() => { + const element = document.querySelector(selector); + if (element) { + callback(element); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + +})(); From 9810d8305b6ec341caecc04e6881653fa86e1bef Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Thu, 21 May 2026 00:29:06 -0500 Subject: [PATCH 20/24] fix: random A/B testing from youtube... this fixes the playlist single feature also clean up some dead code.. --- userscripts/youtube-tweaks.user.js | 119 +++++++++++++++++++---------- 1 file changed, 78 insertions(+), 41 deletions(-) diff --git a/userscripts/youtube-tweaks.user.js b/userscripts/youtube-tweaks.user.js index af0bffe..715a4c0 100644 --- a/userscripts/youtube-tweaks.user.js +++ b/userscripts/youtube-tweaks.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Tweaks -// @version 1.4.0 +// @version 1.4.1 // @description Random tweaks and fixes for YouTube! // @author Journey Over // @license MIT @@ -10,23 +10,20 @@ // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand -// @grant GM_addStyle // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @homepageURL https://github.com/StylusThemes/Userscripts // @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/youtube-tweaks.user.js // @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/youtube-tweaks.user.js // ==/UserScript== -(async function() { +(function() { 'use strict'; const logger = Logger('YT - Tweaks', { debug: false }); - const playSingleIconUrl = 'data:image/svg+xml,' + encodeURIComponent(''); const UI = { overlayId: 'ytt-overlay', modalId: 'ytt-modal', - closeButtonId: 'ytt-close-btn', buttonSelector: 'button-view-model#button-play-single' }; @@ -40,16 +37,12 @@ document.head.appendChild(styleElement); } - function getFeatureStorageKey(featureId) { - return `feature_${featureId}`; - } - function getFeatureEnabledState(featureId, defaultValue) { - return GM_getValue(getFeatureStorageKey(featureId), defaultValue); + return GM_getValue(`feature_${featureId}`, defaultValue); } function setFeatureEnabledState(featureId, enabled) { - GM_setValue(getFeatureStorageKey(featureId), enabled); + GM_setValue(`feature_${featureId}`, enabled); } function startFeatureInstance(feature, localLogger) { @@ -75,8 +68,12 @@ function createPlaySingleButtons() { if (!location.href.includes('/playlist?')) return; - for (const renderer of document.querySelectorAll('ytd-playlist-video-renderer')) { - const anchor = renderer.querySelector('a#thumbnail'); + // Look for both the old renderer and the new lockup models + const renderers = document.querySelectorAll('ytd-playlist-video-renderer, yt-lockup-view-model'); + + for (const renderer of renderers) { + // Look for both the old thumbnail ID and the new class + const anchor = renderer.querySelector('a#thumbnail, a.ytLockupViewModelContentImage'); if (!anchor) continue; const href = anchor.getAttribute('href') || ''; @@ -97,26 +94,77 @@ button.id = 'button-play-single'; const link = document.createElement('a'); - link.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--filled yt-spec-button-shape-next--overlay yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading yt-spec-button-shape-next--enable-backdrop-filter-experiment'; + link.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--text yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-only-default'; link.href = singleUrl; link.setAttribute('aria-label', 'Play Single'); - link.style.paddingRight = '0'; const iconWrapper = document.createElement('div'); iconWrapper.className = 'yt-spec-button-shape-next__icon'; iconWrapper.setAttribute('aria-hidden', 'true'); - const icon = document.createElement('img'); - icon.src = playSingleIconUrl; - icon.style.width = '24px'; - icon.style.height = '24px'; - - iconWrapper.appendChild(icon); + // Build the SVG using DOM methods to bypass YouTube's Trusted Types policy + const svgNS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('width', '24'); + svg.setAttribute('height', '24'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + const path = document.createElementNS(svgNS, 'path'); + path.setAttribute('d', 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'); + svg.appendChild(path); + + const polyline = document.createElementNS(svgNS, 'polyline'); + polyline.setAttribute('points', '15,3 21,3 21,9'); + svg.appendChild(polyline); + + const line = document.createElementNS(svgNS, 'line'); + line.setAttribute('x1', '10'); + line.setAttribute('y1', '14'); + line.setAttribute('x2', '21'); + line.setAttribute('y2', '3'); + svg.appendChild(line); + + const svgContainer = document.createElement('div'); + svgContainer.style.width = '24px'; + svgContainer.style.height = '24px'; + svgContainer.style.display = 'flex'; + svgContainer.style.alignItems = 'center'; + svgContainer.style.justifyContent = 'center'; + svgContainer.appendChild(svg); + + iconWrapper.appendChild(svgContainer); link.appendChild(iconWrapper); button.appendChild(link); - const menu = renderer.querySelector('div#menu'); - if (menu) menu.before(button); + // Find the correct insertion point for either UI version + const oldMenu = renderer.querySelector('div#menu'); + const newMenuContainer = renderer.querySelector('.ytLockupMetadataViewModelMenuButton'); + + if (newMenuContainer) { + // Inject inside the new menu container and force it to act as a flex row + newMenuContainer.style.display = 'flex'; + newMenuContainer.style.alignItems = 'center'; + newMenuContainer.style.flexDirection = 'row'; + newMenuContainer.style.gap = '8px'; + newMenuContainer.prepend(button); + + // Fix overlap with extensions like DeArrow / Clickbait Remover + const textContainer = renderer.querySelector('.ytLockupMetadataViewModelTextContainer'); + if (textContainer && textContainer.style.paddingRight !== '50px') { + // Push the inner contents of the title leftwards to make room for our new button + textContainer.style.paddingRight = '50px'; + textContainer.style.boxSizing = 'border-box'; + } + + } else if (oldMenu) { + button.style.marginRight = '8px'; + oldMenu.before(button); + } } } @@ -228,12 +276,8 @@ function createFeatureManager(featureList) { const featuresById = new Map(featureList.map(feature => [feature.id, feature])); - function forEachFeature(callback) { - for (const feature of featuresById.values()) callback(feature); - } - function init() { - forEachFeature((feature) => { + for (const feature of featuresById.values()) { feature.enabled = getFeatureEnabledState(feature.id, feature.default); if (feature.enabled) { try { @@ -242,7 +286,7 @@ logger.error('Error during feature init', feature.id, error); } } - }); + } } function setEnabled(featureId, enabled) { @@ -267,7 +311,6 @@ function createPlaylistPlaySingleFeature() { const state = { - started: false, onNavigateFinish: null, onAction: null }; @@ -278,7 +321,7 @@ default: true, enabled: false, start() { - if (state.started) return; + if (state.onNavigateFinish) return; createPlaySingleButtons(); state.onNavigateFinish = () => setTimeout(createPlaySingleButtons, 500); @@ -292,10 +335,9 @@ document.addEventListener('yt-navigate-finish', state.onNavigateFinish); document.addEventListener('yt-action', state.onAction); - state.started = true; }, stop() { - if (!state.started) return; + if (!state.onNavigateFinish) return; document.removeEventListener('yt-navigate-finish', state.onNavigateFinish); document.removeEventListener('yt-action', state.onAction); @@ -303,14 +345,12 @@ state.onNavigateFinish = null; state.onAction = null; - state.started = false; } }; } function createOpenVideosNewTabFeature() { const state = { - started: false, onClick: null }; @@ -320,16 +360,14 @@ default: true, enabled: false, start() { - if (state.started) return; + if (state.onClick) return; state.onClick = event => handleOpenVideoClick(event, logger); document.body.addEventListener('click', state.onClick, true); - state.started = true; }, stop() { - if (!state.started) return; + if (!state.onClick) return; document.body.removeEventListener('click', state.onClick, true); state.onClick = null; - state.started = false; } }; } @@ -366,9 +404,9 @@ analyserLeft.fftSize = 32; analyserRight.fftSize = 32; - gain.gain.value = 1; source.connect(splitter); + // Analyser nodes are intentional dead-end sinks — used only for metering via getByteTimeDomainData splitter.connect(analyserLeft, 0); splitter.connect(analyserRight, 1); merger.connect(audioContext.destination); @@ -527,7 +565,6 @@ const header = createElement('div', 'ytt-header'); const title = createElement('div', 'ytt-title', 'YouTube Tweaks'); const closeButton = createElement('button', 'ytt-close', '×'); - closeButton.id = UI.closeButtonId; closeButton.type = 'button'; closeButton.addEventListener('click', removeSettingsModal); From f9258248e50068e6b49714cdeb89b0a18e4d514a Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Fri, 12 Jun 2026 22:09:58 -0500 Subject: [PATCH 21/24] feat: auto-resolve missing tvdb IDs for tv shows Now automatically resolves missing TVDB IDs when formatting TV show YAML. - Prioritizes checking the current page for existing TVDB links. - Falls back to an external API search if no link is found. - Provides live feedback on the button while resolving. - Notifies when an ID has been successfully found and applied. This helps make generating Kometa YAML much smoother when original data is incomplete. --- userscripts/mediux-yaml-to-kometa.user.js | 101 ++++++++++++++++++++-- 1 file changed, 95 insertions(+), 6 deletions(-) diff --git a/userscripts/mediux-yaml-to-kometa.user.js b/userscripts/mediux-yaml-to-kometa.user.js index 840de4b..5484453 100644 --- a/userscripts/mediux-yaml-to-kometa.user.js +++ b/userscripts/mediux-yaml-to-kometa.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name Mediux - YAML to Kometa -// @version 2.3.0 +// @version 2.4.0 // @description Adds buttons to transform MediUX TV and movie YAML into Kometa-compatible metadata. // @author Journey Over // @license MIT @@ -65,23 +65,74 @@ button.classList.remove('text-green-500', 'text-red-500'); button.classList.add('text-gray-400'); }, 3000); + }, + + async resolveTvdbId(showTitle) { + const tvdbLink = document.querySelector('a[href*="thetvdb.com/series/"]'); + if (tvdbLink) { + const match = tvdbLink.href.match(/\/series\/(\d+)/); + if (match) { + logger.debug('TVDB ID resolved from DOM', { showTitle, tvdbId: match[1] }); + return match[1]; + } + } + + try { + const encodedTitle = encodeURIComponent(showTitle.trim()); + const response = await fetch(`https://api.tvmaze.com/search/shows?q=${encodedTitle}`); + + if (!response.ok) { + logger.warn('TVmaze API request failed', { status: response.status, showTitle }); + return null; + } + + const results = await response.json(); + + if (!Array.isArray(results) || results.length === 0) { + logger.debug('TVmaze returned no results', { showTitle }); + return null; + } + + const tvdbId = results[0]?.show?.externals?.thetvdb; + if (tvdbId) { + logger.debug('TVDB ID resolved from TVmaze', { showTitle, tvdbId }); + return String(tvdbId); + } + + logger.debug('TVmaze result missing TVDB ID', { showTitle }); + return null; + } catch (error) { + logger.error('TVmaze fetch failed', { showTitle, error: error.message }); + return null; + } } }, yaml: { - formatTvYml(codeblock, button) { + async formatTvYml(codeblock, button) { let yamlContent = codeblock.textContent; const regexSetInfo = /(null|\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/(\d+))/; const setMatch = yamlContent.match(regexSetInfo); if (setMatch) { - const tvdbId = setMatch[1]; + const originalTvdbId = setMatch[1]; const showTitle = setMatch[2]; const setUrl = setMatch[4]; const setId = setMatch[5]; const year = App.utils.getYear(setId); + let tvdbId = originalTvdbId; + + if (originalTvdbId === 'null') { + const resolvedId = await App.utils.resolveTvdbId(showTitle); + if (resolvedId) { + tvdbId = resolvedId; + } + } + + button.dataset.tvdbResolved = tvdbId !== originalTvdbId ? 'true' : 'false'; + yamlContent = yamlContent.replace(regexSetInfo, `# Posters from:\n# ${setUrl}\n\nmetadata:\n\n ${tvdbId}: # ${showTitle} (${year})`); } @@ -93,7 +144,6 @@ codeblock.innerText = yamlContent; navigator.clipboard.writeText(yamlContent) .then(() => { - App.utils.showNotification('YAML transformed and copied to clipboard!', button); App.utils.updateButtonState(button); }) .catch(error => { @@ -153,14 +203,53 @@ title: 'Copy TV YAML to clipboard', icon: '', text: 'TV', - action: (button) => App.yaml.formatTvYml(codeblock, button) + action: async (button) => { + try { + const span = button.querySelector('span'); + if (span) span.textContent = 'Resolving…'; + button.style.opacity = '0.6'; + button.style.pointerEvents = 'none'; + button.setAttribute('aria-disabled', 'true'); + + await App.yaml.formatTvYml(codeblock, button); + + if (span) span.textContent = 'TV'; + button.style.opacity = '1'; + button.style.pointerEvents = 'auto'; + button.removeAttribute('aria-disabled'); + + const resolved = button.dataset.tvdbResolved === 'true'; + delete button.dataset.tvdbResolved; + const message = resolved ? + 'TVDB ID resolved · YAML copied to clipboard!' : + 'YAML transformed and copied to clipboard!'; + App.utils.showNotification(message, button); + } catch (error) { + logger.error('TV YAML formatting failed', error); + const span = button.querySelector('span'); + if (span) span.textContent = 'TV'; + button.style.opacity = '1'; + button.style.pointerEvents = 'auto'; + button.removeAttribute('aria-disabled'); + App.utils.updateButtonState(button, false); + App.utils.showNotification('Failed to format TV YAML', button); + } + } }, { id: 'fymoviebutton', title: 'Copy Movie YAML to clipboard', icon: '', text: 'Movie', - action: (button) => App.yaml.formatMovieYml(codeblock, button) + action: async (button) => { + try { + await App.yaml.formatMovieYml(codeblock, button); + } catch (error) { + logger.error('Movie YAML formatting failed', error); + App.utils.updateButtonState(button, false); + App.utils.showNotification('Failed to format Movie YAML', button); + } + } } ]; From b5a7a1ded5647f0ed8d6af6e4971174db9a980eb Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Wed, 17 Jun 2026 19:12:43 -0500 Subject: [PATCH 22/24] feat: adapt to latest GitHub changes --- userscripts/github-latest.user.js | 71 ++++++++++++++++++------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/userscripts/github-latest.user.js b/userscripts/github-latest.user.js index b606b99..ebc4d1e 100644 --- a/userscripts/github-latest.user.js +++ b/userscripts/github-latest.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name GitHub - Latest -// @version 1.9.7 +// @version 2.0.0 // @description Always keep an eye on the latest activity of your favorite projects // @author Journey Over // @license MIT @@ -20,16 +20,13 @@ const logger = Logger('GH - Latest', { debug: false }); const BUTTON_ID = 'latest-issues-button'; const QUERY_STRING = 'q=sort%3Aupdated-desc'; - const NAVIGATION_SELECTOR = 'nav[aria-label="Repository"] > ul'; + const NAVIGATION_SELECTOR = 'nav[aria-label="Repository"][data-variant="inset"] > ul[role="list"]'; const findTemplateTab = (navigationBody) => { - // Search for either the issues OR the pulls anchor - const anchor = navigationBody.querySelector('a[href*="/issues"], a[href*="/pulls"]'); - - if (anchor) { - return anchor.closest('li') || anchor.closest(':scope > *') || anchor; + for (const selector of ['a[data-tab-item="issues"]', 'a[data-tab-item="pulls"]', 'a[href*="/issues"], a[href*="/pulls"]']) { + const anchor = navigationBody.querySelector(selector); + if (anchor) return anchor.closest('li') || anchor; } - return null; }; @@ -43,14 +40,17 @@ try { const urlObject = new URL(anchorElement.href, location.origin); - anchorElement.href = `${urlObject.pathname}?${QUERY_STRING}`; + urlObject.search = QUERY_STRING; + anchorElement.href = urlObject.href; } catch { - anchorElement.href = `${(anchorElement.href || '#').split('?')[0]}?${QUERY_STRING}`; + const base = (anchorElement.href || '#').split('?')[0]; + anchorElement.href = base + '?' + QUERY_STRING; } anchorElement.removeAttribute('aria-current'); - anchorElement.style.float = 'none'; - if (clonedTab.style) clonedTab.style.marginLeft = 'auto'; + anchorElement.removeAttribute('data-discover'); + anchorElement.removeAttribute('data-tab-item'); + anchorElement.removeAttribute('data-react-nav'); const svgElement = clonedTab.querySelector('svg'); if (svgElement) { @@ -58,20 +58,24 @@ svgElement.innerHTML = ``; } - const textSpan = clonedTab.querySelector('[data-component="text"]') || clonedTab.querySelector('span'); + const textSpan = clonedTab.querySelector('[data-component="text"]'); if (textSpan) { textSpan.textContent = 'Latest issues'; if (textSpan.hasAttribute('data-content')) textSpan.setAttribute('data-content', 'Latest issues'); } - const counterElement = clonedTab.querySelector('[data-component="counter"], .Counter, .counter'); + const counterElement = clonedTab.querySelector('[data-component="counter"]'); if (counterElement) counterElement.remove(); return clonedTab; }; const addLatestIssuesButton = () => { - if (document.getElementById(BUTTON_ID)) return; + // Skip non-repo pages (e.g. /settings, /notifications, /dashboard) + if (!/^\/[^\/]+\/[^\/]/.test(location.pathname)) return; + + const existingButton = document.getElementById(BUTTON_ID); + if (existingButton && existingButton.isConnected) return; const navigationBody = document.querySelector(NAVIGATION_SELECTOR); if (!navigationBody) { @@ -86,24 +90,33 @@ } logger.debug('Adding latest issues button'); - navigationBody.appendChild(createLatestIssuesTab(templateTab)); + const newTab = createLatestIssuesTab(templateTab); + navigationBody.appendChild(newTab); + + if (location.pathname.includes('/issues') && location.search === '?' + QUERY_STRING) { + const button = document.getElementById(BUTTON_ID); + if (button) button.setAttribute('aria-current', 'page'); + } }; const initialize = () => { logger('Initializing GitHub Latest Issues script'); - - const debouncedAdd = debounce(addLatestIssuesButton, 50); - - debouncedAdd(); - - const observer = new MutationObserver(debouncedAdd); - observer.observe(document.documentElement, { - childList: true, - subtree: true - }); - - document.addEventListener('turbo:render', debouncedAdd); - document.addEventListener('turbo:load', debouncedAdd); + addLatestIssuesButton(); + + // rAF polling: runs every frame (~16ms), pauses when tab is backgrounded. + // Catches ALL removal scenarios (React re-render, CSS hide, tree replace). + // addLatestIssuesButton has isConnected guard — repeated calls are cheap. + const poll = () => { + addLatestIssuesButton(); + requestAnimationFrame(poll); + }; + requestAnimationFrame(poll); + + // Fallback for legacy GitHub pages that still use Turbo/PJAX (Gists, Wiki, etc.) + document.addEventListener('turbo:render', addLatestIssuesButton); + document.addEventListener('turbo:load', addLatestIssuesButton); + document.addEventListener('pjax:end', addLatestIssuesButton); + document.addEventListener('github-pjax', addLatestIssuesButton); }; initialize(); From 0e68f3622843e24be09da9a6e048d11ae4391b90 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Thu, 18 Jun 2026 16:04:58 -0500 Subject: [PATCH 23/24] feat: can't really remember everything I did as it's been over a week since I did it xD --- userscripts/youtube-tweaks.user.js | 435 ++++++++++++++++++----------- 1 file changed, 267 insertions(+), 168 deletions(-) diff --git a/userscripts/youtube-tweaks.user.js b/userscripts/youtube-tweaks.user.js index 715a4c0..aa1c156 100644 --- a/userscripts/youtube-tweaks.user.js +++ b/userscripts/youtube-tweaks.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name YouTube - Tweaks -// @version 1.4.1 +// @version 1.4.2 // @description Random tweaks and fixes for YouTube! // @author Journey Over // @license MIT @@ -21,58 +21,108 @@ const logger = Logger('YT - Tweaks', { debug: false }); + // ========================================== + // 1. CONSTANTS & CONFIGURATION + // ========================================== const UI = { overlayId: 'ytt-overlay', modalId: 'ytt-modal', buttonSelector: 'button-view-model#button-play-single' }; - const css = '#ytt-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);z-index:99999;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity 0.2s ease;font-family:"Roboto","Arial",sans-serif}#ytt-overlay.visible{opacity:1}#ytt-modal{background:#212121;color:#fff;width:400px;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);overflow:hidden;transform:scale(0.95);transition:transform 0.2s ease}#ytt-overlay.visible #ytt-modal{transform:scale(1)}.ytt-header{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.1);display:flex;justify-content:space-between;align-items:center;background:#181818}.ytt-title{font-size:18px;font-weight:500}.ytt-close{background:none;border:none;color:#aaa;font-size:24px;cursor:pointer;line-height:1;padding:0}.ytt-close:hover{color:#fff}.ytt-body{padding:10px 0;max-height:60vh;overflow-y:auto}.ytt-row{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,0.05);transition:background 0.2s}.ytt-row:last-child{border-bottom:none}.ytt-row:hover{background:rgba(255,255,255,0.03)}.ytt-label{font-size:14px;color:#eee}.ytt-switch{position:relative;display:inline-block;width:40px;height:24px}.ytt-switch input{opacity:0;width:0;height:0}.ytt-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#444;transition:.4s;border-radius:24px}.ytt-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background-color:white;transition:.4s;border-radius:50%}input:checked+.ytt-slider{background-color:#f00}input:checked+.ytt-slider:before{transform:translateX(16px)}.ytt-footer{padding:12px 20px;background:#181818;border-top:1px solid rgba(255,255,255,0.1);text-align:right;font-size:12px;color:#888}'; + const STYLES = '#ytt-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.6);backdrop-filter:blur(2px);z-index:99999;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity 0.2s ease;font-family:"Roboto","Arial",sans-serif}#ytt-overlay.visible{opacity:1}#ytt-modal{background:#212121;color:#fff;width:400px;border-radius:12px;box-shadow:0 10px 40px rgba(0,0,0,0.5);border:1px solid rgba(255,255,255,0.1);overflow:hidden;transform:scale(0.95);transition:transform 0.2s ease}#ytt-overlay.visible #ytt-modal{transform:scale(1)}.ytt-header{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.1);display:flex;justify-content:space-between;align-items:center;background:#181818}.ytt-title{font-size:18px;font-weight:500}.ytt-close{background:none;border:none;color:#aaa;font-size:24px;cursor:pointer;line-height:1;padding:0}.ytt-close:hover{color:#fff}.ytt-body{padding:10px 0;max-height:60vh;overflow-y:auto}.ytt-row{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,0.05);transition:background 0.2s}.ytt-row:last-child{border-bottom:none}.ytt-row:hover{background:rgba(255,255,255,0.03)}.ytt-label{font-size:14px;color:#eee}.ytt-switch{position:relative;display:inline-block;width:40px;height:24px}.ytt-switch input{opacity:0;width:0;height:0}.ytt-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#444;transition:.4s;border-radius:24px}.ytt-slider:before{position:absolute;content:"";height:18px;width:18px;left:3px;bottom:3px;background-color:white;transition:.4s;border-radius:50%}input:checked+.ytt-slider{background-color:#f00}input:checked+.ytt-slider:before{transform:translateX(16px)}.ytt-footer{padding:12px 20px;background:#181818;border-top:1px solid rgba(255,255,255,0.1);text-align:right;font-size:12px;color:#888}'; + + // ========================================== + // 2. CORE UTILITIES + // ========================================== + const Utilities = { + injectStyle(styleText) { + const styleElement = document.createElement('style'); + styleElement.textContent = styleText; + document.head.appendChild(styleElement); + }, + createElement(tagName, className, textContent) { + const element = document.createElement(tagName); + if (className) element.className = className; + if (typeof textContent === 'string') element.textContent = textContent; + return element; + }, + storage: { + get(featureId, defaultValue) { + return GM_getValue(`feature_${featureId}`, defaultValue); + }, + set(featureId, enabled) { + GM_setValue(`feature_${featureId}`, enabled); + } + } + }; - const playerEvents = ['loadedmetadata', 'play', 'ratechange', 'seeked', 'timeupdate']; + // ========================================== + // 3. MODULE-LEVEL HELPER FUNCTIONS + // ========================================== - function injectStyle(styleText) { - const styleElement = document.createElement('style'); - styleElement.textContent = styleText; - document.head.appendChild(styleElement); + function calculateRms(buffer) { + let total = 0; + for (const value of buffer) { + const normalized = (value - 128) / 128; + total += normalized * normalized; + } + return Math.sqrt(total / buffer.length); } - function getFeatureEnabledState(featureId, defaultValue) { - return GM_getValue(`feature_${featureId}`, defaultValue); + function getVideoIdFromUrl(urlString) { + try { + const url = new URL(urlString, location.href); + if (url.pathname.startsWith('/watch')) return url.searchParams.get('v'); + if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2] || null; + } catch { + return null; + } + return null; } - function setFeatureEnabledState(featureId, enabled) { - GM_setValue(`feature_${featureId}`, enabled); - } + function formatDuration(seconds) { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); - function startFeatureInstance(feature, localLogger) { - if (feature.enabled) return; - feature.enabled = true; - try { - feature.start(); - } catch (error) { - localLogger.error('Error starting feature', feature.id, error); - } + const dayPrefix = days > 0 ? `${days}:` : ''; + const hourText = String(hours).padStart(2, '0'); + const minuteText = String(minutes).padStart(2, '0'); + const secondText = String(secs).padStart(2, '0'); + return `${dayPrefix}${hourText}:${minuteText}:${secondText}`; } - function stopFeatureInstance(feature, localLogger) { - if (!feature.enabled) return; - feature.enabled = false; - try { - feature.stop(); - } catch (error) { - localLogger.error('Error stopping feature', feature.id, error); + function expandSearchTimeText(text) { + const match = text.match(/^(\d+)\s*(s|m|h|d|w|mo|y)\s*ago$/i); + if (!match) return text; + + const value = parseInt(match[1], 10); + const unitLetter = match[2].toLowerCase(); + let unit = ''; + + switch (unitLetter) { + case 's': { unit = 'second'; break; } + case 'm': { unit = 'minute'; break; } + case 'h': { unit = 'hour'; break; } + case 'd': { unit = 'day'; break; } + case 'w': { unit = 'week'; break; } + case 'mo': { unit = 'month'; break; } + case 'y': { unit = 'year'; break; } + default: { return text; } } + + if (value !== 1) unit += 's'; + return value + ' ' + unit + ' ago'; } function createPlaySingleButtons() { if (!location.href.includes('/playlist?')) return; - // Look for both the old renderer and the new lockup models const renderers = document.querySelectorAll('ytd-playlist-video-renderer, yt-lockup-view-model'); for (const renderer of renderers) { - // Look for both the old thumbnail ID and the new class const anchor = renderer.querySelector('a#thumbnail, a.ytLockupViewModelContentImage'); if (!anchor) continue; @@ -102,7 +152,6 @@ iconWrapper.className = 'yt-spec-button-shape-next__icon'; iconWrapper.setAttribute('aria-hidden', 'true'); - // Build the SVG using DOM methods to bypass YouTube's Trusted Types policy const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('width', '24'); @@ -141,26 +190,21 @@ link.appendChild(iconWrapper); button.appendChild(link); - // Find the correct insertion point for either UI version const oldMenu = renderer.querySelector('div#menu'); const newMenuContainer = renderer.querySelector('.ytLockupMetadataViewModelMenuButton'); if (newMenuContainer) { - // Inject inside the new menu container and force it to act as a flex row newMenuContainer.style.display = 'flex'; newMenuContainer.style.alignItems = 'center'; newMenuContainer.style.flexDirection = 'row'; newMenuContainer.style.gap = '8px'; newMenuContainer.prepend(button); - // Fix overlap with extensions like DeArrow / Clickbait Remover const textContainer = renderer.querySelector('.ytLockupMetadataViewModelTextContainer'); if (textContainer && textContainer.style.paddingRight !== '50px') { - // Push the inner contents of the title leftwards to make room for our new button textContainer.style.paddingRight = '50px'; textContainer.style.boxSizing = 'border-box'; } - } else if (oldMenu) { button.style.marginRight = '8px'; oldMenu.before(button); @@ -168,44 +212,7 @@ } } - function getVideoIdFromUrl(urlString) { - try { - const url = new URL(urlString, location.href); - if (url.pathname.startsWith('/watch')) return url.searchParams.get('v'); - if (url.pathname.startsWith('/shorts/')) return url.pathname.split('/')[2] || null; - } catch { - return null; - } - return null; - } - - function calculateRms(buffer) { - let total = 0; - for (const value of buffer) { - const normalized = (value - 128) / 128; - total += normalized * normalized; - } - return Math.sqrt(total / buffer.length); - } - - function formatDuration(seconds) { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - const dayPrefix = days > 0 ? `${days}:` : ''; - const hourText = String(hours).padStart(2, '0'); - const minuteText = String(minutes).padStart(2, '0'); - const secondText = String(secs).padStart(2, '0'); - return `${dayPrefix}${hourText}:${minuteText}:${secondText}`; - } - - function getTimeContainer() { - return document.querySelector('.ytp-time-contents') || document.querySelector('.ytp-time-display'); - } - - function handleOpenVideoClick(event, localLogger) { + function handleOpenVideoClick(event) { try { const link = event.target.closest?.('a'); if (!link?.href || link.target === '_blank' || link.hasAttribute('download')) return; @@ -230,7 +237,7 @@ window.open(link.href, '_blank'); } } catch (error) { - localLogger.error('openVideosNewTab handler error', error); + logger.error('openVideosNewTab handler error', error); } } @@ -238,7 +245,7 @@ const video = document.querySelector('.video-stream.html5-main-video'); if (!video || Number.isNaN(video.duration)) return; - const timeContainer = getTimeContainer(); + const timeContainer = document.querySelector('.ytp-time-contents') || document.querySelector('.ytp-time-display'); if (!timeContainer) return; const adjustedDuration = video.duration / video.playbackRate; @@ -273,42 +280,93 @@ if (endSpan.textContent !== finishText) endSpan.textContent = finishText; } - function createFeatureManager(featureList) { - const featuresById = new Map(featureList.map(feature => [feature.id, feature])); + function fixLayouts() { + // PART 1: SIDEBAR / EXPERIMENTAL DESIGN ROWS (WITH TRIANGLE) + const targetPath = 'M5 4.623v14.755a1.5 1.5 0 002.261 1.294l12.766-7.51L22 12.002l-1.973-1.162L7.26 3.33A1.5 1.5 0 005 4.623Zm2 13.88V5.497L18.056 12 7 18.503Z'; + const paths = document.querySelectorAll('svg path'); + + for (const path of paths) { + if (path.getAttribute('d') === targetPath) { + const metadataRow = path.closest('.ytContentMetadataViewModelMetadataRow'); + + if (metadataRow && !metadataRow.hasAttribute('data-views-fixed')) { + const hostContainer = metadataRow.closest('.ytContentMetadataViewModelHost'); + if (hostContainer) { + hostContainer.style.setProperty('display', 'flex', 'important'); + hostContainer.style.setProperty('flex-direction', 'column', 'important'); + hostContainer.style.setProperty('align-items', 'flex-start', 'important'); + hostContainer.style.maxWidth = '100%'; + } + + metadataRow.style.flexWrap = 'nowrap'; + metadataRow.style.overflow = 'visible'; + metadataRow.style.marginTop = '2px'; + + const iconWrapper = metadataRow.querySelector('.ytContentMetadataViewModelLeadingIcon'); + if (iconWrapper) iconWrapper.style.display = 'none'; + + const countSpan = metadataRow.querySelector('span[aria-label*="view"]'); + if (countSpan) { + let fullViewText = countSpan.getAttribute('aria-label'); + if (fullViewText) { + fullViewText = fullViewText.replace(/\s+thousand\s+views/i, 'K views'); + fullViewText = fullViewText.replace(/\s+million\s+views/i, 'M views'); + fullViewText = fullViewText.replace(/\s+billion\s+views/i, 'B views'); + countSpan.textContent = fullViewText; + } else { + countSpan.textContent = countSpan.textContent.trim() + ' views'; + } + } + + const delimiter = metadataRow.querySelector('.ytContentMetadataViewModelDelimiter'); + if (delimiter) delimiter.textContent = ' \u2022 '; - function init() { - for (const feature of featuresById.values()) { - feature.enabled = getFeatureEnabledState(feature.id, feature.default); - if (feature.enabled) { - try { - feature.start(); - } catch (error) { - logger.error('Error during feature init', feature.id, error); + const timeSpan = metadataRow.querySelector('span[aria-label*="ago"]'); + if (timeSpan) { + const fullTimeText = timeSpan.getAttribute('aria-label'); + if (fullTimeText) timeSpan.textContent = fullTimeText; } + + metadataRow.setAttribute('data-views-fixed', 'true'); } } } - function setEnabled(featureId, enabled) { - const feature = featuresById.get(featureId); - if (!feature) return; + // PART 2: SEARCH RESULTS / CLASSIC LIST ROWS (WITHOUT TRIANGLE) + const searchItems = document.querySelectorAll('.inline-metadata-item.ytd-video-meta-block:not([data-time-fixed])'); - setFeatureEnabledState(featureId, enabled); - if (enabled) startFeatureInstance(feature, logger); - else stopFeatureInstance(feature, logger); - } + for (const item of searchItems) { + const text = item.textContent.trim(); + if (!text) continue; + + if (text.endsWith('ago')) { + item.textContent = expandSearchTimeText(text); + item.setAttribute('data-time-fixed', 'true'); + } - function list() { - return [...featuresById.values()]; + const isCount = /^[0-9\.,]+[KMB]?$/i.test(text); + if (isCount && !text.includes('view') && !text.endsWith('ago')) { + item.textContent = text + ' views'; + item.setAttribute('data-time-fixed', 'true'); + } } + } - return { - init, - list, - setEnabled - }; + function toggleFeature(feature, enable) { + if (feature.enabled === enable) return; + feature.enabled = enable; + try { + if (enable) feature.start(); + else feature.stop(); + } catch (error) { + logger.error(`Error ${enable ? 'starting' : 'stopping'} feature`, feature.id, error); + } } + // ========================================== + // 4. FEATURE IMPLEMENTATIONS + // ========================================== + function createPlaylistPlaySingleFeature() { const state = { onNavigateFinish: null, @@ -361,7 +419,7 @@ enabled: false, start() { if (state.onClick) return; - state.onClick = event => handleOpenVideoClick(event, logger); + state.onClick = handleOpenVideoClick; document.body.addEventListener('click', state.onClick, true); }, stop() { @@ -406,7 +464,6 @@ analyserRight.fftSize = 32; source.connect(splitter); - // Analyser nodes are intentional dead-end sinks — used only for metering via getByteTimeDomainData splitter.connect(analyserLeft, 0); splitter.connect(analyserRight, 1); merger.connect(audioContext.destination); @@ -423,13 +480,8 @@ const leftSilent = calculateRms(leftData) < 0.02; const rightSilent = calculateRms(rightData) < 0.02; - try { - splitter.disconnect(); - } catch {} - - try { - gain.disconnect(); - } catch {} + try { splitter.disconnect(); } catch {} + try { gain.disconnect(); } catch {} if (leftSilent || rightSilent) { splitter.connect(gain, 0); @@ -460,9 +512,7 @@ start() { if (state.observer) return; - state.observer = new MutationObserver(() => { - applyToExistingVideos(); - }); + state.observer = new MutationObserver(applyToExistingVideos); state.observer.observe(document.body, { childList: true, subtree: true }); applyToExistingVideos(); @@ -476,6 +526,8 @@ } function createActualTimeDisplayFeature() { + const playerEvents = ['loadedmetadata', 'play', 'ratechange', 'seeked', 'timeupdate']; + const state = { observer: null, video: null, @@ -533,90 +585,137 @@ }; } - function createElement(tagName, className, textContent) { - const element = document.createElement(tagName); - if (className) element.className = className; - if (typeof textContent === 'string') element.textContent = textContent; - return element; + function createLayoutFixFeature() { + const state = { observer: null }; + + return { + id: 'layoutFix', + name: 'Fix metadata layout (views, dates, search results)', + default: true, + enabled: false, + start() { + if (state.observer) return; + state.observer = new MutationObserver(() => fixLayouts()); + state.observer.observe(document.body, { childList: true, subtree: true }); + fixLayouts(); + }, + stop() { + if (state.observer) { + state.observer.disconnect(); + state.observer = null; + } + } + }; } - function removeSettingsModal() { - const overlay = document.getElementById(UI.overlayId); - if (!overlay) return; + // ========================================== + // 5. FEATURE MANAGER + // ========================================== + function createFeatureManager(featureList) { + const featuresById = new Map(featureList.map(feature => [feature.id, feature])); - overlay.classList.remove('visible'); - setTimeout(() => { - overlay.remove(); - }, 200); + return { + init() { + for (const feature of featuresById.values()) { + const isEnabled = Utilities.storage.get(feature.id, feature.default); + toggleFeature(feature, isEnabled); + } + }, + list() { + return [...featuresById.values()]; + }, + setEnabled(featureId, enabled) { + const feature = featuresById.get(featureId); + if (!feature) return; + Utilities.storage.set(featureId, enabled); + toggleFeature(feature, enabled); + } + }; } - function createSettingsModal(featureManager) { - if (document.getElementById(UI.overlayId)) return; + // ========================================== + // 6. SETTINGS UI MANAGER + // ========================================== + const SettingsUI = { + removeModal() { + const overlay = document.getElementById(UI.overlayId); + if (!overlay) return; + + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 200); + }, + + createModal(featureManager) { + if (document.getElementById(UI.overlayId)) return; + + const overlay = Utilities.createElement('div'); + overlay.id = UI.overlayId; + overlay.addEventListener('click', (event) => { + if (event.target === overlay) this.removeModal(); + }); - const overlay = createElement('div'); - overlay.id = UI.overlayId; - overlay.addEventListener('click', (event) => { - if (event.target === overlay) removeSettingsModal(); - }); + const modal = Utilities.createElement('div'); + modal.id = UI.modalId; - const modal = createElement('div'); - modal.id = UI.modalId; + const header = Utilities.createElement('div', 'ytt-header'); + const title = Utilities.createElement('div', 'ytt-title', 'YouTube Tweaks'); - const header = createElement('div', 'ytt-header'); - const title = createElement('div', 'ytt-title', 'YouTube Tweaks'); - const closeButton = createElement('button', 'ytt-close', '×'); - closeButton.type = 'button'; - closeButton.addEventListener('click', removeSettingsModal); + const closeButton = Utilities.createElement('button', 'ytt-close', '×'); + closeButton.type = 'button'; + closeButton.addEventListener('click', () => this.removeModal()); - header.appendChild(title); - header.appendChild(closeButton); + header.appendChild(title); + header.appendChild(closeButton); - const body = createElement('div', 'ytt-body'); + const body = Utilities.createElement('div', 'ytt-body'); - for (const feature of featureManager.list()) { - const row = createElement('div', 'ytt-row'); - const label = createElement('span', 'ytt-label', feature.name); - const switchLabel = createElement('label', 'ytt-switch'); + for (const feature of featureManager.list()) { + const row = Utilities.createElement('div', 'ytt-row'); + const label = Utilities.createElement('span', 'ytt-label', feature.name); + const switchLabel = Utilities.createElement('label', 'ytt-switch'); - const input = createElement('input'); - input.type = 'checkbox'; - input.checked = !!feature.enabled; - input.addEventListener('change', () => { - featureManager.setEnabled(feature.id, input.checked); - }); + const input = Utilities.createElement('input'); + input.type = 'checkbox'; + input.checked = !!feature.enabled; + input.addEventListener('change', () => { + featureManager.setEnabled(feature.id, input.checked); + }); - const slider = createElement('span', 'ytt-slider'); + const slider = Utilities.createElement('span', 'ytt-slider'); - switchLabel.appendChild(input); - switchLabel.appendChild(slider); - row.appendChild(label); - row.appendChild(switchLabel); - body.appendChild(row); - } + switchLabel.appendChild(input); + switchLabel.appendChild(slider); + row.appendChild(label); + row.appendChild(switchLabel); + body.appendChild(row); + } - modal.appendChild(header); - modal.appendChild(body); - overlay.appendChild(modal); - document.body.appendChild(overlay); + modal.appendChild(header); + modal.appendChild(body); + overlay.appendChild(modal); + document.body.appendChild(overlay); - requestAnimationFrame(() => { - overlay.classList.add('visible'); - }); - } + requestAnimationFrame(() => overlay.classList.add('visible')); + } + }; - injectStyle(css); + // ========================================== + // 7. INITIALIZATION BOOTSTRAP + // ========================================== + Utilities.injectStyle(STYLES); const featureManager = createFeatureManager([ createPlaylistPlaySingleFeature(), createOpenVideosNewTabFeature(), createMonoAudioFixFeature(), - createActualTimeDisplayFeature() + createActualTimeDisplayFeature(), + createLayoutFixFeature() ]); featureManager.init(); try { - GM_registerMenuCommand('Open YouTube Tweaks Settings', () => createSettingsModal(featureManager)); + GM_registerMenuCommand('Open YouTube Tweaks Settings', () => SettingsUI.createModal(featureManager)); } catch (error) { logger.error('Failed to register menu command', error); } From a9b927a548c2f301c54c2594b5fdc2d03b080536 Mon Sep 17 00:00:00 2001 From: JourneyOver Date: Thu, 25 Jun 2026 03:39:02 -0500 Subject: [PATCH 24/24] fix: don't run script until github nav is ready makes sure the navigation bar is loaded before making DOM changes so as to no longer make the search bar disappear upon clicking it.. Ref: https://github.com/refined-github/refined-github/pull/9725 Closes #10 --- userscripts/github-latest.user.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/userscripts/github-latest.user.js b/userscripts/github-latest.user.js index ebc4d1e..3560d0c 100644 --- a/userscripts/github-latest.user.js +++ b/userscripts/github-latest.user.js @@ -1,6 +1,6 @@ // ==UserScript== // @name GitHub - Latest -// @version 2.0.0 +// @version 2.0.1 // @description Always keep an eye on the latest activity of your favorite projects // @author Journey Over // @license MIT @@ -74,6 +74,9 @@ // Skip non-repo pages (e.g. /settings, /notifications, /dashboard) if (!/^\/[^\/]+\/[^\/]/.test(location.pathname)) return; + // Wait for the global nav bar to finish loading before making DOM changes + if (!document.querySelector('[partial-name="global-nav-bar"].loaded')) return; + const existingButton = document.getElementById(BUTTON_ID); if (existingButton && existingButton.isConnected) return;