Skip to content

Instantly share code, notes, and snippets.

@wilt00
Last active November 21, 2022 08:50
Show Gist options
  • Save wilt00/17623ffbc0fb496cda843ddde487b13c to your computer and use it in GitHub Desktop.
Save wilt00/17623ffbc0fb496cda843ddde487b13c to your computer and use it in GitHub Desktop.
Add a section to the YouTube Subscriptions page sidebar, letting you filter videos by length, channel, and type
// ==UserScript==
// @name Filter Youtube Subscriptions
// @match https://www.youtube.com/*
// @grant none
// @version 1.1
// @author wilt00
// @description Add a section to the YouTube Subscriptions page sidebar, letting you filter videos by length, channel, and type
// @homepage https://gist.github.com/wilt00/17623ffbc0fb496cda843ddde487b13c
// @updateURL https://gist.github.com/wilt00/17623ffbc0fb496cda843ddde487b13c/raw/7f24c0945df306a1032d1635f9a77445063bc7fc/filterYoutubeSubscriptions.user.js
// @downloadURL https://gist.github.com/wilt00/17623ffbc0fb496cda843ddde487b13c/raw/7f24c0945df306a1032d1635f9a77445063bc7fc/filterYoutubeSubscriptions.user.js
// ==/UserScript==
(() => {
const VERBOSE = false;
const log = (s) => VERBOSE && console.log(s);
const debug = (s) => VERBOSE && console.debug(s);
log("Beginning filter userscript");
const getTime = (e) =>
String(
e.querySelector("span.ytd-thumbnail-overlay-time-status-renderer")
?.innerText
).trim();
const getChannel = (e) =>
e.querySelector("yt-formatted-string.ytd-channel-name")?.innerText ||
"(Unknown Channel)";
const isLive = (e) =>
Boolean(e.querySelector("div.badge-style-type-live-now"));
const isUpcoming = (e) =>
String(e.querySelector("div#metadata-line")?.innerText).includes(
"Scheduled for"
);
const getTimeSeconds = (timeString) => {
const numbers = (timeString || "")
.split(":")
.map((n) => parseInt(n, 10))
.reverse();
const seconds = numbers[0] || 0;
const minutes = (numbers[1] || 0) * 60;
const hours = (numbers[2] || 0) * 3600;
return seconds + minutes + hours;
};
const getGrid = () =>
Array.from(document.getElementsByTagName("ytd-grid-video-renderer")).map(
(target) => {
const live = isLive(target);
const upcoming = isUpcoming(target);
const video = !live && !upcoming;
return {
target,
timeString: getTime(target),
channel: getChannel(target),
live,
upcoming,
video,
type: live ? "live" : upcoming ? "upcoming" : "video",
};
}
);
// Assume grid is loaded when one of the last two videos has a time
const gridLoaded = () =>
Array.isArray(GRID) && GRID.slice(-2).some((v) => v.timeString);
const calculateTimes = () =>
GRID.forEach((v) => {
v.timeValue = getTimeSeconds(v.timeString);
});
const countChannelVideos = () =>
GRID.reduce(
(acc, v) => ({
...acc,
[v.channel]: (acc[v.channel] || 0) + 1,
}),
{}
);
const sortChannelCounts = (counts) =>
Object.entries(counts).sort(([, v1], [, v2]) => v2 - v1);
const formHtml = `
<style>
.fys-section-header {
color: var(--yt-spec-text-primary);
font-size: 1.5em;
margin-top: 5px;
margin-bottom: 5px;
}
.fys-field-label {
color: var(--yt-spec-text-primary);
font-size: 1.25em;
}
.slider-label {
color: var(--yt-spec-text-primary);
margin-bottom: 5px;
}
</style>
<div style="margin: 20px;">
<fieldset>
<legend class="fys-section-header">Video Types</legend>
<input type="checkbox" id="show-video" name="show-types" value="videos" checked>
<label class="fys-field-label" for="show-video">Videos</label><br/>
<input type="checkbox" id="show-live" name="show-types" value="live" checked>
<label class="fys-field-label" for="show-live">Streams - Live</label><br/>
<input type="checkbox" id="show-upcoming" name="show-types" value="upcoming" checked>
<label class="fys-field-label" for="show-upcoming">Streams - Upcoming</label><br/>
</fieldset>
<fieldset>
<legend class="fys-section-header">Video Length</legend>
<input type="range" id="min-length" name="min-length" min="0" max="3600" value="0"><br/>
<div class="slider-label"><label class="fys-field-label" for="min-length">Min: <span id="min-length-value-label">00:00:00</span></label></div>
<input type="range" id="max-length" name="max-length" min="0" max="7200" value="7200"><br/>
<div class="slider-label"><label class="fys-field-label" for="max-length">Max: <span id="max-length-value-label">2:00:00</span></label></div>
<input type="checkbox" id="no-max-length" name="no-max-length" checked>
<label class="fys-field-label" for="no-max-length">No Max Length</label><br/>
</fieldset>
<details>
<summary class="fys-section-header">Filter Channels</summary>
<fieldset id="filter-channel-section">
<div id="filter-channel-list-container"></div>
</fieldset>
</details>
<button id="reload-grid">Reload</button>
</div>
`;
// restrict html ids to alphanumeric chars; hopefully we don't get collisions
const channelInputId = (c) =>
`channel-checkbox-${c.replace(/[^a-zA-Z0-9]/g, '')}`;
const channelCheckbox = ([channel, videos]) => `
<input
type="checkbox"
class="channel-checkbox"
id="${channelInputId(channel)}"
value="${channel}"
${filterState.filteredChannels.has(channel) ? "" : "checked"}
>
<label class="fys-field-label" for="channel-checkbox-${channel}">${channel} (${videos})</label><br/>
`;
const getSubscriptionsLink = () =>
Array.from(document.querySelectorAll("ytd-guide-entry-renderer")).filter(
(e) => e.innerText === "Subscriptions"
)[0];
const applyFilters = () => {
GRID.forEach((v) => {
const canSeeType = filterState[v.type];
const inTimeRange =
(filterState.noMaxLength || v.timeValue <= filterState.length.max) &&
v.timeValue >= filterState.length.min;
const canSeeChannel = !filterState.filteredChannels.has(v.channel);
v.target.hidden = !(canSeeType && inTimeRange && canSeeChannel);
});
};
const videoTypeListener = (key) => ({ target: { checked } }) => {
filterState[key] = checked;
applyFilters();
};
const addTypeListener = (key) =>
document
.querySelector(`#show-${key}`)
.addEventListener("change", videoTypeListener(key));
const zeroPad = (num) => `${num < 10 ? '0' : ''}${num}`;
const getTimeLabel = (input) => {
const inputNum = parseInt(input, 10);
const hours = Math.floor(inputNum / 3600);
const minutes = Math.floor((inputNum % 3600) / 60);
const seconds = inputNum % 60;
return `${zeroPad(hours)}:${zeroPad(minutes)}:${zeroPad(seconds)}`;
}
const addLengthListener = (key) => {
const input = document.querySelector(`#${key}-length`);
const label = document.querySelector(`#${key}-length-value-label`);
input.addEventListener('change', ({ target: { value }}) => {
filterState.length[key] = parseInt(value, 10);
if (key === 'max') {
document.querySelector('#no-max-length').checked = false;
filterState.noMaxLength = false;
}
applyFilters();
});
input.addEventListener('input', ({ target: { value }}) => {
label.innerText = getTimeLabel(value);
})
};
const addLengthListeners = () => {
addLengthListener('min');
addLengthListener('max');
document.querySelector('#no-max-length').addEventListener('change', ({ target: { checked }}) => { filterState.noMaxLength = checked; applyFilters(); });
}
const channelListener = ({ target: { checked, value } }) => {
filterState.filteredChannels[checked ? "delete" : "add"](value);
applyFilters();
};
const updateChannelCheckboxes = () => {
const filterChannelSection = document.querySelector(
"#filter-channel-list-container"
);
filterChannelSection.textContent = "";
const allChannels = sortChannelCounts(countChannelVideos());
debug({ allChannels });
const channels = allChannels.filter(
([c, v]) => v > 2 || filterState.filteredChannels.has(c)
);
debug({ channels });
channels
.map(channelCheckbox)
.forEach((h) => filterChannelSection.insertAdjacentHTML("beforeend", h));
channels.forEach(([c]) => {
try {
document
.querySelector(`#${channelInputId(c)}`)
?.addEventListener("change", channelListener);
} catch (e) {
log(e);
log(c);
}
});
};
const doUpdate = () => {
log('doing update now!')
GRID = getGrid();
calculateTimes();
updateChannelCheckboxes();
applyFilters();
}
const buildForm = () => {
subscriptionsLink.insertAdjacentHTML("afterend", formHtml);
addTypeListener("video");
addTypeListener("live");
addTypeListener("upcoming");
addLengthListeners();
updateChannelCheckboxes();
document.querySelector("#reload-grid").onclick = doUpdate;
};
let subscriptionsLink;
let GRID;
const filterState = {
video: true,
live: true,
upcoming: true,
length: { min: 0, max: 9999999 },
// minTime: 0,
// maxTime: 9999999, // 115 days, should be good
noMaxLength: true,
filteredChannels: new Set(),
};
// Adapted from Underscore.js via https://davidwalsh.name/javascript-debounce-function
function debounce(func, wait) {
let timeout;
return function() {
const context = this;
const args = arguments;
const later = function() {
timeout = null;
func.apply(context, args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
const observer = new MutationObserver(debounce(doUpdate, 750));
function waitForPageLoad() {
setTimeout(() => {
subscriptionsLink = getSubscriptionsLink();
GRID = getGrid();
if (subscriptionsLink && gridLoaded()) {
calculateTimes();
log(GRID);
buildForm();
observer.observe(
document.querySelector('div#contents.ytd-section-list-renderer'),
{ childList: true, subtree: true }
);
} else {
log("still waiting...");
waitForPageLoad();
}
}, 1000);
}
waitForPageLoad();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment