Skip to content

Instantly share code, notes, and snippets.

@Kladki
Last active January 31, 2026 16:45
Show Gist options
  • Select an option

  • Save Kladki/101f0da6511906460252e26ea78d8ec3 to your computer and use it in GitHub Desktop.

Select an option

Save Kladki/101f0da6511906460252e26ea78d8ec3 to your computer and use it in GitHub Desktop.
DeArrow user script with playlist sidebar support
// ==UserScript==
// @name DeArrow for Invidious
// @namespace http://tampermonkey.net/
// @version 0.2.8
// @description Adds support for DeArrow in Invidious
// @match https://yewtu.be/*
// @match https://iv.ggtyler.dev/*
// @match http://192.168.8.8:3000/*
// @icon https://dearrow.ajay.app/logo.svg
// @grant GM.xmlHttpRequest
// @author Macic-Dev
// @author Minion3665
// @author Basil
// @author Ajay
// @author Euphoriyy
// @author Matthias Ahouansou
// @author Pet (beesarefriends)
// ==/UserScript==
(function() { //Not async because the only needed await is now in its own function (replaceCurrentVideo)
'use strict';
/**
* A simple fetch polyfill that uses the GreaseMonkey API
*
* @param {string} url - the URL to fetch
*/
function fetch(url) {
return new Promise(resolve => {
GM.xmlHttpRequest({
method: 'GET',
url,
responseType: 'blob',
onload: res => {
const headers = res.responseHeaders
.split('\r\n')
.reduce((prev, str) => {
const [key, val] = str.split(/: (.*)/s);
if (key === '') return prev;
prev[key.toLowerCase()] = val;
return prev;
}, {});
resolve({
headers: {
get: key => headers[key.toLowerCase()]
},
status: res.status,
blob: async () => res.response,
json: async () => JSON.parse(await new Response(res.response).text())
});
}
});
});
}
/**
* Converts a Blob to a data URL in order to get around CSP
*
* @param {Blob} blob - the URL to fetch
*/
function blobToDataURI(blob) {
return new Promise(resolve => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => resolve(reader.result);
});
}
/**
* Fetch data for a video, and then update the elements passed to reflect that
*
* @param {string} videoId - the YouTube ID of the video (the bit that comes after v= in watch URLs)
* @param {HTMLElement} titleElement - the element containing the video title which will be updated if DeArrow has a title
* @param {HTMLElement|undefined} thumbnailElement - the element containing the video thumbnail which will be updated if DeArrow has a thumbnail
*/
async function fetchAndUpdate(videoId, titleElement, thumbnailElement = undefined) {
const oldTitle = titleElement.textContent;
const oldThumbnail = thumbnailElement?.src;
const cachedThumbnailAPIResponse = await fetch(`https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}`);
const cachedNewTitle = cachedThumbnailAPIResponse.headers.get('X-Title');
if (cachedNewTitle) replaceTitle(titleElement, cachedNewTitle);
if (thumbnailElement !== undefined && cachedThumbnailAPIResponse.status === 200 && cachedThumbnailAPIResponse.headers.get("X-Timestamp") != "0.0") {
const cachedNewThumbnail = await cachedThumbnailAPIResponse.blob();
thumbnailElement.src = await blobToDataURI(cachedNewThumbnail);
}
const brandingAPIResponse = await (await fetch(`https://sponsor.ajay.app/api/branding?videoID=${videoId}`)).json();
{
let topTitle = brandingAPIResponse.titles[0];
let usedTitle = (topTitle && (topTitle.votes >= 0 || topTitle.locked)) ? topTitle : null;
replaceTitle(titleElement, usedTitle?.title ?? oldTitle);
}
if (brandingAPIResponse.thumbnails.length > 0) {
let topThumbnail = brandingAPIResponse.thumbnails[0];
let usedThumbnail = (topThumbnail && (topThumbnail.votes >= 0 || topThumbnail.locked)) ? topThumbnail : null;
let randomTime = brandingAPIResponse.videoDuration ? brandingAPIResponse.videoDuration * brandingAPIResponse.randomTime : 0;
if (usedThumbnail && usedThumbnail.original) {
thumbnailElement.src = oldThumbnail;
} else {
const votedThumbnailAPIResponse = await fetch(`https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${usedThumbnail?.timestamp ?? randomTime}`);
thumbnailElement.src = await blobToDataURI(await votedThumbnailAPIResponse.blob());
}
}
}
/**
* Replaces the title of elements
*
* @param {HTMLElement} element - the element containing the video title
* @param {HTMLElement} text - the text to replace the video title with
*/
function replaceTitle(element, text) {
let formText = text; // If not using formatting below
// let formText = text.replace(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g, '').toLowerCase(); // Remove emoji, make lowercase
if (element.nodeName === 'H1') {
for (const child of element.childNodes) {
if (child.nodeName !== '#text') continue;
//TODO: format title
child.textContent = formText;
break;
}
} else {
element.textContent = formText;
}
}
/**
* Replaces the title and thumbnail of videos on the page (not the main loaded video)
*/
function replacePageVideos() {
// console.log("replacing page videos");
const thumbnailDivs = document.querySelectorAll('div.thumbnail');
thumbnailDivs.forEach((div) => {
if (div.parentNode.nodeName === 'DIV') {
// First link. Selector could be better.
const link = div.querySelector('a');
// Make sure we aren't fetching channel URLs
if (link.href.startsWith('/channel/')) return;
const videoUrl = new URL(link.href);
const videoId = videoUrl.searchParams.get('v');
const titleP = div.parentNode.children[1].querySelector('p');
const thumbnailImg = div.querySelector('img.thumbnail');
fetchAndUpdate(videoId, titleP, thumbnailImg);
} else {
// Make sure we aren't fetching channel URLs
if (div.parentNode.href.startsWith('/channel/')) return;
const videoUrl = new URL(div.querySelector('a').href);
const videoId = videoUrl.searchParams.get('v');
const titleP = [...div.parentNode.querySelectorAll('p')][1];
const thumbnailImg = div.querySelector('img.thumbnail');
fetchAndUpdate(videoId, titleP, thumbnailImg);
}
});
}
/**
* Replaces the title and thumbnail of the current (main) video
*/
async function replaceCurrentVideo() {
const currentUrl = new URL(document.URL);
if (currentUrl.pathname == '/watch') {
const currentId = currentUrl.searchParams.get('v');
const titleH = document.querySelector('h1');
const thumbnailDiv = document.querySelector('.vjs-poster');
await fetchAndUpdate(currentId, titleH, thumbnailDiv);
if (thumbnailDiv.src) thumbnailDiv.style.backgroundImage = `url(${thumbnailDiv.src})`;
document.title = `${titleH.textContent} - Invidious`;
}
}
replaceCurrentVideo();
replacePageVideos();
// Replaces the playlist video list which appears when playing a video as part of a playlist
// This needs to be run after page load, as this list is loaded with javascript
function replacePlaylistSidebarVideos() {
const thumbnailDivs = document.querySelectorAll('div.thumbnail');
thumbnailDivs.forEach((div) => {
if (div.parentNode.nodeName === 'A') {
const videoUrl = new URL(div.parentNode.href);
const videoId = videoUrl.searchParams.get('v');
const titleP = div.parentNode.children[1];
const thumbnailImg = div.querySelector('img.thumbnail');
fetchAndUpdate(videoId, titleP, thumbnailImg);
}
});
}
// Use MutationObserver to watch for changes in the playlist div
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
// https://stackoverflow.com/a/47406751
const playlistNode = document.getElementById("playlist");
(new MutationObserver(checkChange)).observe(playlistNode, {childList: true, subtree: true});
function checkChange(changes, observer) {
if(playlistNode.querySelector('.playlist-restricted.pure-menu-scrollable.pure-menu')) { // If we find the scrollable menu in the playlist (ie it's loaded fully)
observer.disconnect(); // Stop the observer
replacePlaylistSidebarVideos();
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment