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/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. diff --git a/README.md b/README.md index 2364d01..256e0f9 100644 --- a/README.md +++ b/README.md @@ -161,14 +161,14 @@ 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 - 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) | | 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/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/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; } diff --git a/userscripts/dmm-add-trash-buttons.user.js b/userscripts/dmm-add-trash-buttons.user.js index 39a5ca0..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.0 +// @version 4.0.2 // @description Adds buttons to Debrid Media Manager for applying Trash Guide regex patterns. // @author Journey Over // @license MIT @@ -42,6 +42,8 @@ CACHE_DURATION: 24 * 60 * 60 * 1000, 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', diff --git a/userscripts/github-latest.user.js b/userscripts/github-latest.user.js index c2a35c7..3560d0c 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 2.0.1 // @description Always keep an eye on the latest activity of your favorite projects // @author Journey Over // @license MIT @@ -20,24 +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 debounce = (callback, wait) => { - let timeout; - return (...callbackArguments) => { - clearTimeout(timeout); - timeout = setTimeout(() => callback.apply(this, callbackArguments), wait); - }; - }; + 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; }; @@ -51,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) { @@ -66,20 +58,27 @@ 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; + + // 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; const navigationBody = document.querySelector(NAVIGATION_SELECTOR); if (!navigationBody) { @@ -94,24 +93,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'); + 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); - 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); + // 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(); diff --git a/userscripts/magnet-link-to-real-debrid.user.js b/userscripts/magnet-link-to-real-debrid.user.js index 8d06600..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.0 +// @version 2.12.3 // @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 { @@ -85,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)); }, @@ -97,7 +97,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; }, }; @@ -113,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 { @@ -126,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)); @@ -151,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) { @@ -292,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; } @@ -402,13 +398,13 @@ 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) { this.injectStyles(); - const html = ``; + const html = ``; const overlay = document.createElement('div'); overlay.className = 'rd-overlay'; @@ -429,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(); }); @@ -474,7 +470,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 +525,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 +573,6 @@ }; updateStates(fileTree.root); - // Sync folder checkboxes in DOM const syncCheckboxes = (node, element) => { if (node.type === 'folder') { const checkbox = element.querySelector('.rd-checkbox'); @@ -612,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'); @@ -678,9 +671,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) { @@ -721,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; @@ -734,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'); @@ -753,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 @@ -810,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() : ''; @@ -825,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 { @@ -841,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) { @@ -865,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 : []); } @@ -873,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 : []); } @@ -894,10 +924,6 @@ }); }); } - - isTorrentSupportEnabled() { - return this.#config.enableTorrentSupport; - } } class PageIntegrator { @@ -906,7 +932,6 @@ this.observer = null; this.keyToIcon = new Map(); this.selectedLinks = new Set(); - this.totalMagnetLinks = 0; this.initialMagnetLinkCount = 0; this.batchButton = null; } @@ -975,32 +1000,35 @@ 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; 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'); + } try { await this.processor.processMagnetLink(url); successCount++; - if (icon) UIManager.setIconState(icon, 'added', config.enableTorrentSupport); + for (const icon of icons) { + UIManager.setIconState(icon, 'added'); + } } catch (error) { errorCount++; - if (icon) UIManager.setIconState(icon, 'default', config.enableTorrentSupport); + for (const icon of icons) { + UIManager.setIconState(icon, 'default'); + } logger.error(`[Batch Processing] Failed to process ${url}`, error); } } - // Clear selections after processing this.selectedLinks.clear(); this._updateBatchButton(); @@ -1010,9 +1038,24 @@ } _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) { + 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) { @@ -1020,19 +1063,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; @@ -1052,45 +1087,44 @@ 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'); return; } - UIManager.setIconState(icon, 'processing', torrentSupport); + 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', torrentSupport); + UIManager.setIconState(icon, 'added'); } catch (error) { - UIManager.setIconState(icon, 'default', torrentSupport); + 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(); - if (icon.textContent === '✓') return; // Already processed + 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(); }); } } 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(); @@ -1100,40 +1134,31 @@ } this.initialMagnetLinkCount = uniqueHashes.size; } - - const config = ConfigManager.getConfigSync(); - const torrentSupport = config.enableTorrentSupport; - - const newlyAddedKeys = []; + let hasNewIcons = false; for (const link of links) { if (!link.parentNode) continue; 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); - } + 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) : - UIManager.createMagnetIcon(torrentSupport); + 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.keyToIcon.set(storeKey, iconContainer); - newlyAddedKeys.push(storeKey); + this._storeIconForKey(key, iconContainer); + hasNewIcons = true; } - if (newlyAddedKeys.length) { + if (hasNewIcons) { ensureApiInitialized().then(isInitialized => { if (isInitialized) this.markExistingTorrents(); }); @@ -1143,14 +1168,14 @@ markExistingTorrents() { 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'); + } } } } @@ -1193,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; @@ -1201,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); @@ -1225,9 +1250,12 @@ }) .catch(error => { logger.warn('[Initialization] Failed to initialize Real-Debrid integration', error); + _apiInitPromise = null; return false; }); + _apiInitPromise = initPromise; + return _apiInitPromise; } diff --git a/userscripts/mediux-autofill-description.user.js b/userscripts/mediux-autofill-description.user.js index 90ae485..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.0 +// @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() { @@ -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.

    diff --git a/userscripts/mediux-yaml-fixes.user.js b/userscripts/mediux-yaml-fixes.user.js deleted file mode 100644 index d13c101..0000000 --- a/userscripts/mediux-yaml-fixes.user.js +++ /dev/null @@ -1,516 +0,0 @@ -// ==UserScript== -// @name Mediux - Yaml Fixes -// @version 2.2.3 -// @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 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'; - }, - - 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 = /(\d+): # TVDB id for (.*?)\. Set by (.*?) on MediUX\. (https:\/\/mediux\.pro\/sets\/\d+)/; - - const year = MediuxFixes.utils.getYear(); - - const setMatch = yamlContent.match(regexSetInfo); - if (setMatch) { - const tvdbId = setMatch[1]; - const showTitle = setMatch[2]; - const setUrl = setMatch[4]; - - 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..5484453 --- /dev/null +++ b/userscripts/mediux-yaml-to-kometa.user.js @@ -0,0 +1,306 @@ +// ==UserScript== +// @name Mediux - YAML to Kometa +// @version 2.4.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); + }, + + 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: { + 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 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})`); + } + + 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.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: 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: 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); + } + } + } + ]; + + 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 }); + } + +})(); 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; }; diff --git a/userscripts/youtube-filters.user.js b/userscripts/youtube-filters.user.js index db7ab08..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.1 +// @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 @@ -74,12 +74,68 @@ 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 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'; + 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 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 = { + debugEnabled: 'DEBUG_ENABLED', + ageThreshold: 'AGE_THRESHOLD', + membersOnlyEnabled: 'MEMBERS_ONLY_ENABLED', + ageFilteringEnabled: 'AGE_FILTERING_ENABLED', + liveVideosEnabled: 'LIVE_VIDEOS_ENABLED', + premiereVideosEnabled: 'PREMIERE_VIDEOS_ENABLED' + }; const UI = { overlayId: 'ytf-overlay', @@ -90,12 +146,13 @@ 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); + 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) { @@ -111,55 +168,56 @@ 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) { - return root.querySelectorAll(selectors.join(',')); + function queryAll(root, selectorQuery) { + return root.querySelectorAll(selectorQuery); } // ---------- 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)) { + for (const ageElement of queryAll(videoElement, AGE_SELECTOR_QUERY)) { 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) { @@ -173,28 +231,24 @@ } 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'; - } - } + 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})`); } // ---------- Age Filtering ---------- function filterVideoByAge(videoElement) { - if (processedVideos.has(videoElement)) return; - const { text: ageText, years: ageYears } = getVideoAgeTextAndYears(videoElement); - if (ageText === 'Unknown') return; - - processedVideos.add(videoElement); - videoElement.dataset.processed = 'true'; + if (ageText === UNKNOWN_AGE_TEXT) return; const thresholdInYears = convertToYears(AGE_THRESHOLD.value, AGE_THRESHOLD.unit); if (ageYears >= thresholdInYears) { @@ -202,15 +256,76 @@ } } + /** + * 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_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'; + } + 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. + * + * @param {Element} badge + * @returns {boolean} + */ 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); } 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)}"`); @@ -221,14 +336,14 @@ 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(); } } } 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); } @@ -239,13 +354,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('@')) { - filterVideoByAge(videoElement); - } + applyVideoFilters(videoElement, shouldFilterAges); } if (MEMBERS_ONLY_ENABLED) pruneMembersShelf(); } catch (error) { @@ -253,39 +365,55 @@ } } - 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; } + 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); + } } } }); 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); @@ -296,9 +424,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 ---------- @@ -346,7 +472,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); @@ -368,13 +494,38 @@ return row; } + /** + * Persists settings to userscript storage. + * + * @param {{ + * ageFilteringEnabled: boolean, + * ageThreshold: { value: number, unit: string }, + * membersOnlyEnabled: boolean, + * liveVideosEnabled: boolean, + * premiereVideosEnabled: 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.liveVideosEnabled, settings.liveVideosEnabled); + GM_setValue(SETTINGS_KEYS.premiereVideosEnabled, settings.premiereVideosEnabled); + 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, + liveVideosEnabled: LIVE_VIDEOS_ENABLED, + premiereVideosEnabled: PREMIERE_VIDEOS_ENABLED, + debugEnabled: DEBUG_ENABLED + }; const overlay = createElement('div'); overlay.id = UI.overlayId; @@ -400,20 +551,28 @@ 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(draftSettings.ageThreshold, (newThreshold) => { + draftSettings.ageThreshold = newThreshold; + })); + + body.appendChild(createToggleRow('Hide Members-only Videos', draftSettings.membersOnlyEnabled, (checked) => { + draftSettings.membersOnlyEnabled = checked; })); - body.appendChild(createThresholdRow(temporaryAgeThreshold, (newThreshold) => { - temporaryAgeThreshold = newThreshold; + body.appendChild(createToggleRow('Hide LIVE Videos', draftSettings.liveVideosEnabled, (checked) => { + draftSettings.liveVideosEnabled = checked; })); - body.appendChild(createToggleRow('Hide Members-only Videos', temporaryMembersOnlyEnabled, (checked) => { - temporaryMembersOnlyEnabled = checked; + body.appendChild(createToggleRow('Hide PREMIERE Videos', draftSettings.premiereVideosEnabled, (checked) => { + draftSettings.premiereVideosEnabled = 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'); @@ -423,10 +582,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(); }); 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(); - -})(); diff --git a/userscripts/youtube-tweaks.user.js b/userscripts/youtube-tweaks.user.js index af0bffe..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.0 +// @version 1.4.2 // @description Random tweaks and fixes for YouTube! // @author Journey Over // @license MIT @@ -10,73 +10,120 @@ // @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(''); + // ========================================== + // 1. CONSTANTS & CONFIGURATION + // ========================================== const UI = { overlayId: 'ytt-overlay', modalId: 'ytt-modal', - closeButtonId: 'ytt-close-btn', 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 getFeatureStorageKey(featureId) { - return `feature_${featureId}`; + 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 getFeatureEnabledState(featureId, defaultValue) { - return GM_getValue(getFeatureStorageKey(featureId), defaultValue); - } + 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 setFeatureEnabledState(featureId, enabled) { - GM_setValue(getFeatureStorageKey(featureId), enabled); + 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 startFeatureInstance(feature, localLogger) { - if (feature.enabled) return; - feature.enabled = true; - try { - feature.start(); - } catch (error) { - localLogger.error('Error starting 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; } } - } - function stopFeatureInstance(feature, localLogger) { - if (!feature.enabled) return; - feature.enabled = false; - try { - feature.stop(); - } catch (error) { - localLogger.error('Error stopping feature', feature.id, error); - } + if (value !== 1) unit += 's'; + return value + ' ' + unit + ' ago'; } function createPlaySingleButtons() { if (!location.href.includes('/playlist?')) return; - for (const renderer of document.querySelectorAll('ytd-playlist-video-renderer')) { - const anchor = renderer.querySelector('a#thumbnail'); + const renderers = document.querySelectorAll('ytd-playlist-video-renderer, yt-lockup-view-model'); + + for (const renderer of renderers) { + const anchor = renderer.querySelector('a#thumbnail, a.ytLockupViewModelContentImage'); if (!anchor) continue; const href = anchor.getAttribute('href') || ''; @@ -97,67 +144,75 @@ 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); + 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); - } - } + const oldMenu = renderer.querySelector('div#menu'); + const newMenuContainer = renderer.querySelector('.ytLockupMetadataViewModelMenuButton'); - 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; - } + if (newMenuContainer) { + newMenuContainer.style.display = 'flex'; + newMenuContainer.style.alignItems = 'center'; + newMenuContainer.style.flexDirection = 'row'; + newMenuContainer.style.gap = '8px'; + newMenuContainer.prepend(button); - function calculateRms(buffer) { - let total = 0; - for (const value of buffer) { - const normalized = (value - 128) / 128; - total += normalized * normalized; + const textContainer = renderer.querySelector('.ytLockupMetadataViewModelTextContainer'); + if (textContainer && textContainer.style.paddingRight !== '50px') { + textContainer.style.paddingRight = '50px'; + textContainer.style.boxSizing = 'border-box'; + } + } else if (oldMenu) { + button.style.marginRight = '8px'; + oldMenu.before(button); + } } - 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; @@ -182,7 +237,7 @@ window.open(link.href, '_blank'); } } catch (error) { - localLogger.error('openVideosNewTab handler error', error); + logger.error('openVideosNewTab handler error', error); } } @@ -190,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; @@ -225,49 +280,95 @@ 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%'; + } - function forEachFeature(callback) { - for (const feature of featuresById.values()) callback(feature); - } + 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'; + } + } - function init() { - forEachFeature((feature) => { - 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 delimiter = metadataRow.querySelector('.ytContentMetadataViewModelDelimiter'); + if (delimiter) delimiter.textContent = ' \u2022 '; + + 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 = { - started: false, onNavigateFinish: null, onAction: null }; @@ -278,7 +379,7 @@ default: true, enabled: false, start() { - if (state.started) return; + if (state.onNavigateFinish) return; createPlaySingleButtons(); state.onNavigateFinish = () => setTimeout(createPlaySingleButtons, 500); @@ -292,10 +393,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 +403,12 @@ state.onNavigateFinish = null; state.onAction = null; - state.started = false; } }; } function createOpenVideosNewTabFeature() { const state = { - started: false, onClick: null }; @@ -320,16 +418,14 @@ default: true, enabled: false, start() { - if (state.started) return; - state.onClick = event => handleOpenVideoClick(event, logger); + if (state.onClick) return; + state.onClick = handleOpenVideoClick; 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,7 +462,6 @@ analyserLeft.fftSize = 32; analyserRight.fftSize = 32; - gain.gain.value = 1; source.connect(splitter); splitter.connect(analyserLeft, 0); @@ -385,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); @@ -422,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(); @@ -438,6 +526,8 @@ } function createActualTimeDisplayFeature() { + const playerEvents = ['loadedmetadata', 'play', 'ratechange', 'seeked', 'timeupdate']; + const state = { observer: null, video: null, @@ -495,91 +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.id = UI.closeButtonId; - 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); }