Last active
November 21, 2022 08:50
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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