YouTube - Filters

Filters YouTube videos by duration and age. Hides videos less than X seconds long or older than a specified number of years, excluding channel video tabs.

// ==UserScript==
// @name          YouTube - Filters
// @version       1.4.4
// @description   Filters YouTube videos by duration and age. Hides videos less than X seconds long or older than a specified number of years, excluding channel video tabs.
// @author        Journey Over
// @license       MIT
// @match         *://*.youtube.com/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@5f2cbff53b0158ca07c86917994df0ed349eb96c/libs/gm/gmcompat.js
// @grant         GM.setValue
// @grant         GM.getValue
// @grant         GM.registerMenuCommand
// @run-at        document-body
// @icon          https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(async function() {
  'use strict';

  // Retrieve settings or use defaults via GMC (async)
  const MIN_DURATION_SECONDS = await GMC.getValue('MIN_DURATION_SECONDS', 120);
  const AGE_THRESHOLD_YEARS = await GMC.getValue('AGE_THRESHOLD_YEARS', 4);
  const ENABLE_CONSOLE_LOGS = await GMC.getValue('ENABLE_CONSOLE_LOGS', true);

  const processedVideos = new Set(); // To keep track of processed video containers
  let scheduledFilter = null; // throttle flag for mutation observer

  /* ---------------- Settings UI ---------------- */
  function openSettingsMenu() {
    const settingsContainer = document.createElement('div');
    settingsContainer.id = 'yt-filters-settings';
    settingsContainer.style.position = 'fixed';
    settingsContainer.style.top = '50%';
    settingsContainer.style.left = '50%';
    settingsContainer.style.transform = 'translate(-50%, -50%)';
    settingsContainer.style.backgroundColor = '#282c34';
    settingsContainer.style.color = '#abb2bf';
    settingsContainer.style.padding = '20px';
    settingsContainer.style.borderRadius = '10px';
    settingsContainer.style.zIndex = '10000';
    settingsContainer.style.boxShadow = '0 0 20px rgba(0, 0, 0, 0.5)';
    settingsContainer.style.maxWidth = '500px';
    settingsContainer.style.maxHeight = '500px';
    settingsContainer.style.overflowY = 'auto';
    settingsContainer.style.fontFamily = 'Arial, sans-serif';

    settingsContainer.innerHTML = `
      <div>
        <h3 style="margin: 0 0 15px 0; font-size: 1.5em; color: #61dafb;">Video Filters Settings</h3>
        <label for="min-duration" style="display: block; margin-bottom: 5px;">Minimum Duration (seconds):</label>
        <input type="number" id="min-duration" value="${MIN_DURATION_SECONDS}" style="width: calc(100% - 22px); padding: 8px; border: 1px solid #444c56; border-radius: 5px; background: #3e4451; color: #abb2bf; margin-bottom: 15px;">
        <label for="age-threshold" style="display: block; margin-bottom: 5px;">Age Threshold (years):</label>
        <input type="number" id="age-threshold" value="${AGE_THRESHOLD_YEARS}" style="width: calc(100% - 22px); padding: 8px; border: 1px solid #444c56; border-radius: 5px; background: #3e4451; color: #abb2bf; margin-bottom: 15px;">
        <label for="enable-logs" style="display: block; margin-bottom: 5px;">Enable Console Logs:</label>
        <input type="checkbox" id="enable-logs" ${ENABLE_CONSOLE_LOGS ? 'checked' : ''} style="margin-bottom: 15px;">
        <div style="display: flex; justify-content: space-between;">
          <button id="save-settings" style="flex: 1; margin-right: 10px; padding: 10px; background-color: #61dafb; color: #282c34; border: none; border-radius: 5px; cursor: pointer;">Save</button>
          <button id="close-settings" style="flex: 1; padding: 10px; background-color: #e06c75; color: #282c34; border: none; border-radius: 5px; cursor: pointer;">Close</button>
        </div>
      </div>
    `;
    document.body.appendChild(settingsContainer);

    const saveButton = document.getElementById('save-settings');
    const closeButton = document.getElementById('close-settings');

    saveButton.addEventListener('click', async () => {
      const newMinDuration = parseInt(document.getElementById('min-duration').value, 10);
      const newAgeThreshold = parseInt(document.getElementById('age-threshold').value, 10);
      const newEnableLogs = document.getElementById('enable-logs').checked;
      await GMC.setValue('MIN_DURATION_SECONDS', newMinDuration);
      await GMC.setValue('AGE_THRESHOLD_YEARS', newAgeThreshold);
      await GMC.setValue('ENABLE_CONSOLE_LOGS', newEnableLogs);
      alert('Settings saved!');
      settingsContainer.remove();
      window.location.reload();
    });

    closeButton.addEventListener('click', () => {
      settingsContainer.remove();
    });
  }

  // register menu command
  GMC.registerMenuCommand('Open YouTube Filters Settings', openSettingsMenu);

  /* ---------------- Utilities ---------------- */
  function convertDurationToSeconds(durationText) {
    // Handles HH:MM:SS, MM:SS, or single "SS" just in case.
    if (!durationText) return 0;
    // remove whitespace and any non-digit/: characters
    const sanitized = durationText.trim().replace(/[^\d:]/g, '');
    if (!sanitized) return 0;
    const parts = sanitized.split(':').map(p => parseInt(p, 10) || 0).reverse();
    let seconds = 0;
    for (let i = 0; i < parts.length; i++) {
      seconds += parts[i] * Math.pow(60, i);
    }
    return seconds;
  }

  function isShortVideo(durationInSeconds) {
    return durationInSeconds < MIN_DURATION_SECONDS && durationInSeconds !== 0;
  }

  // Extract "X years ago" style info from a container (works with new and old YouTube DOM)
  function getVideoAgeTextAndYears(container) {
    // Search any text nodes or spans near metadata rows that include "ago"
    const texts = Array.from(container.querySelectorAll('span, .yt-core-attributed-string, .yt-content-metadata-view-model-wiz__metadata-text'))
      .map(el => el.innerText && el.innerText.trim())
      .filter(Boolean);

    const ageText = texts.find(t => /\bago\b/i.test(t));
    if (ageText) {
      const yearsMatch = ageText.match(/(\d+)\s+(year|years)\s+ago/i);
      return {
        text: ageText,
        years: yearsMatch ? parseInt(yearsMatch[1], 10) : 0
      };
    }
    return {
      text: 'Unknown',
      years: 0
    };
  }

  function getVideoTitle(container) {
    // new markup
    const newTitle = container.querySelector('.yt-lockup-metadata-view-model-wiz__title, .yt-lockup-metadata-view-model-wiz__heading-reset a');
    if (newTitle) {
      // the title text may be inside an inner span
      const inner = newTitle.querySelector('span') || newTitle;
      return inner.innerText ? inner.innerText.trim() : (newTitle.title || '');
    }
    // fallback to legacy selector
    const legacy = container.querySelector('#video-title');
    return legacy ? legacy.innerText.trim() : '';
  }

  function getDurationText(container) {
    // try new markup badge text
    let badge = container.querySelector('.badge-shape-wiz__text, yt-thumbnail-badge-view-model .badge-shape-wiz__text');
    if (badge && badge.innerText.trim()) return badge.innerText.trim();

    // legacy markup fallback
    const legacy = container.querySelector('span.ytd-thumbnail-overlay-time-status-renderer, .ytd-thumbnail-overlay-time-status-renderer');
    if (legacy && legacy.innerText) return legacy.innerText.trim();

    // sometimes duration is on the thumbnail overlay element
    const overlay = container.querySelector('[aria-label*="duration"], .yt-thumbnail-overlay-time-status-renderer');
    if (overlay && overlay.innerText) return overlay.innerText.trim();

    return '';
  }

  /* ---------------- Detection of channel / videos tab ---------------- */
  function isChannelVideosPage() {
    // matches /@name/videos or /channel/ /c/ /user/ followed by /videos
    const path = window.location.pathname || '';
    const channelVideosRegex = /^\/(?:(?:@[^\/]+)|(?:channel|c|user)\/[^\/]+)\/videos(\/.*)?$/i;
    return channelVideosRegex.test(path);
  }

  /* ---------------- Main filtering ---------------- */
  function collectVideoContainers() {
    const containers = new Set();

    // Preferred: anchors that link to watch pages — covers many renderers including new markup
    const watchAnchors = document.querySelectorAll('a[href*="/watch?v="]');
    watchAnchors.forEach(a => {
      // try to find a known container ancestor
      const container = a.closest('div.yt-lockup-view-model-wiz') ||
                        a.closest('ytd-rich-item-renderer') ||
                        a.closest('ytd-compact-video-renderer') ||
                        a.closest('ytd-video-renderer') ||
                        a.closest('ytd-playlist-panel-video-renderer') ||
                        a.closest('div.yt-lockup') ||
                        a.closest('div'); // last-resort (will be filtered)
      if (container) containers.add(container);
    });

    // Also include older renderer elements that might not have an anchor in the same way
    Array.from(document.querySelectorAll('ytd-rich-item-renderer, ytd-compact-video-renderer, ytd-video-renderer, div.yt-lockup-view-model-wiz'))
      .forEach(el => containers.add(el));

    return Array.from(containers);
  }

  function filterVideos() {
    // Don't run on channel /videos pages
    if (isChannelVideosPage()) {
      if (ENABLE_CONSOLE_LOGS) console.log('[YT Filters] Skipping channel /videos page.');
      return;
    }

    const containers = collectVideoContainers();

    containers.forEach(container => {
      if (!container || processedVideos.has(container)) return;

      const title = getVideoTitle(container);
      const durationText = getDurationText(container) || '';
      const durationInSeconds = convertDurationToSeconds(durationText);
      const {
        text: videoAgeText,
        years: videoAgeInYears
      } = getVideoAgeTextAndYears(container);

      if (isShortVideo(durationInSeconds)) {
        if (ENABLE_CONSOLE_LOGS) {
          console.log(`%cDuration Removal: %c"${title}" %c(${durationText})`, "color: red;", "color: orange;", "color: deepskyblue;");
        }
        container.style.display = 'none';
        processedVideos.add(container);
      } else if (videoAgeInYears >= AGE_THRESHOLD_YEARS) {
        if (ENABLE_CONSOLE_LOGS) {
          console.log(`%cAge Removal: %c"${title}" %c(${videoAgeText})`, "color: red;", "color: orange;", "color: deepskyblue;");
        }
        container.style.display = 'none';
        processedVideos.add(container);
      } else {
        // If previously hidden by you but now ok, don't unhide automatically — keep it simple.
        processedVideos.add(container); // mark processed to avoid reprocessing
      }
    });
  }

  /* ---------------- Mutation observer (throttled) ---------------- */
  const observer = new MutationObserver((mutations) => {
    if (scheduledFilter) return;
    scheduledFilter = setTimeout(() => {
      try {
        filterVideos();
      } catch (e) {
        if (ENABLE_CONSOLE_LOGS) console.error('[YT Filters] Error during filter:', e);
      } finally {
        scheduledFilter = null;
      }
    }, 250); // small throttle
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true
  });

  // Initial run after a short delay to allow content to render
  setTimeout(filterVideos, 600);

})();