ajayyy / DeArrow

Crowdsourcing better titles and thumbnails on YouTube
https://dearrow.ajay.app
GNU General Public License v3.0
1.48k stars 41 forks source link

Support Piped/Invidious #39

Open GlenLowland opened 1 year ago

GlenLowland commented 1 year ago

Please add support for 3rd party front-ends, similar to SponsorBlock. Specifically, for Piped.

codenyte commented 1 year ago

Invidious would be nice as well, I use Piped myself, but it's always better to have multiple options

ajayyy commented 1 year ago

Do note that this implementation will only work in some cases, since it is relying on cached information existing.

It will only work if someone with the main extension has seen that video once previously.

Minion3665 commented 1 year ago

I wrote a userscript for Invidious:

I updated your userscript for Invidious

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Adds support for DeArrow in Invidious
// @author       You
// @match        https://inv.vern.cc/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        none
// @author       Macic-Dev
// @author       Minion3665
// ==/UserScript==

(async function() {
    'use strict';

    /**
     * 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) titleElement.textContent = cachedNewTitle;

      if (thumbnailElement !== undefined && fetch.status === 200) {
        const cachedNewThumbnail = await cachedThumbnailAPIResponse.blob();
        thumbnailElement.src = URL.createObjectURL(cachedNewThumbnail);
      }

      const brandingAPIResponse = await (await fetch(`https://sponsor.ajay.app/api/branding?videoID=${videoId}`)).json();
      {
        let maxVotes = -1;
        let maxVotesTitle = undefined;

        for (const title of brandingAPIResponse.titles) {
          if (title.votes > maxVotes) {
            maxVotes = title.votes;
            maxVotesTitle = title.title;
          }
        }

        titleElement.textContent = maxVotesTitle ?? oldTitle;
      }
      {
        let maxVotes = -1;
        let maxVotesThumbnailTime = undefined;
        let maxVotesThumbnailIsOriginal = false;

        for (const thumbnail of brandingAPIResponse.thumbnails) {
          if (thumbnail.votes > maxVotes) {
            maxVotes = thumbnail.votes;
            maxVotesThumbnailTime = thumbnail.timestamp;
            maxVotesThumbnailIsOriginal = thumbnail.original;
          }

          if (maxVotesThumbnailIsOriginal) {
            thumbnailElement.src = oldThumbnail;
          } else {
            const votedThumbnailAPIResponse = await fetch(`https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${maxVotesThumbnailTime ?? brandingAPIResponse.randomTime}`);
            thumbnailElement.src = URL.createObjectURL(await votedThumbnailAPIResponse.blob());
          }
        }
      }
    }

    // Videos on page
    const thumbnailDivs = document.querySelectorAll('div.thumbnail');
    thumbnailDivs.forEach((div) => {
        const videoUrl = new URL(div.parentNode.href);
        const videoId = videoUrl.searchParams.get('v');
        const titleP = [...div.parentNode.querySelectorAll('p')][1];
        const thumbnailImg = div.querySelector('img.thumbnail');
        void fetchAndUpdate(videoId, titleP, thumbnailImg);
    });

    // Current video
    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`;
    }
})();

Stuff I changed:

Let me know if you notice any issues with mine!

gBasil commented 1 year ago

I wrote a userscript for Invidious:

I updated your userscript for Invidious

Stuff I changed:

* Document title changes on video pages

* Ask the API rather than just trusting the cache

* Update the thumbnail before pressing play on the video pages

Let me know if you notice any issues with mine!

I updated your userscript for Invidious

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.1
// @description  Adds support for DeArrow in Invidious
// @author       You
// @match        http://yt.owo/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        none
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// ==/UserScript==

(async function() {
    'use strict';

    /**
     * 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 && fetch.status === 200) {
        const cachedNewThumbnail = await cachedThumbnailAPIResponse.blob();
        thumbnailElement.src = URL.createObjectURL(cachedNewThumbnail);
      }

      const brandingAPIResponse = await (await fetch(`https://sponsor.ajay.app/api/branding?videoID=${videoId}`)).json();
      {
        let maxVotes = -1;
        let maxVotesTitle = undefined;

        for (const title of brandingAPIResponse.titles) {
          if (title.votes > maxVotes) {
            maxVotes = title.votes;
            maxVotesTitle = title.title;
          }
        }

        replaceTitle(titleElement, maxVotesTitle ?? oldTitle);
      }
      {
        let maxVotes = -1;
        let maxVotesThumbnailTime = undefined;
        let maxVotesThumbnailIsOriginal = false;

        for (const thumbnail of brandingAPIResponse.thumbnails) {
          if (thumbnail.votes > maxVotes) {
            maxVotes = thumbnail.votes;
            maxVotesThumbnailTime = thumbnail.timestamp;
            maxVotesThumbnailIsOriginal = thumbnail.original;
          }

          if (maxVotesThumbnailIsOriginal) {
            thumbnailElement.src = oldThumbnail;
          } else {
            const votedThumbnailAPIResponse = await fetch(`https://dearrow-thumb.ajay.app/api/v1/getThumbnail?videoID=${videoId}&time=${maxVotesThumbnailTime ?? brandingAPIResponse.randomTime}`);
            thumbnailElement.src = URL.createObjectURL(await votedThumbnailAPIResponse.blob());
          }
        }
      }
    }

    // Replaces the title of elements
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    const thumbnailDivs = document.querySelectorAll('div.thumbnail');
    thumbnailDivs.forEach((div) => {
      const videoUrl = new URL(div.parentNode.href);
      const videoId = videoUrl.searchParams.get('v');
      const titleP = [...div.parentNode.querySelectorAll('p')][1];
      const thumbnailImg = div.querySelector('img.thumbnail');
      void fetchAndUpdate(videoId, titleP, thumbnailImg);
    });

    // Current video
    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`;
    }
})();

Stuff I changed:

Edit: It looks like Invidious does something with the title, so duplicate text may appear. Looking into it. Fixed.

Edit 2: Fixed another mistake.

solisinvictum commented 1 year ago

Thanks for your afford to make this possible.

But i dont know, and didnt find a option or so in dearrow, where i could put this user script in.

gBasil commented 1 year ago

Userscripts are managed with a different extension (like Violentmonkey), not DeArrow.

aggarwalsushant commented 1 year ago

@gBasil seems your script doesn't work at all as of now (may be due to a change in invidious or what). I tried with violentmonkey in Brave and FF. Didn't make any changes to your script whatsoever.

ajayyy commented 1 year ago

I have correct some issues in the script:

  1. Data is already returned sorted in the correct order, should always use [0] if it is locked or has votes
  2. Fixed how random time is handled
// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.1
// @description  Adds support for DeArrow in Invidious
// @author       You
// @match        http://yt.owo/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        none
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// @author       Ajay
// ==/UserScript==

(async function() {
    'use strict';

    /**
     * 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 && fetch.status === 200) {
        const cachedNewThumbnail = await cachedThumbnailAPIResponse.blob();
        thumbnailElement.src = URL.createObjectURL(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);
      }
      {
        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 = URL.createObjectURL(await votedThumbnailAPIResponse.blob());
        }
      }
    }

    // Replaces the title of elements
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    const thumbnailDivs = document.querySelectorAll('div.thumbnail');
    thumbnailDivs.forEach((div) => {
      const videoUrl = new URL(div.parentNode.href);
      const videoId = videoUrl.searchParams.get('v');
      const titleP = [...div.parentNode.querySelectorAll('p')][1];
      const thumbnailImg = div.querySelector('img.thumbnail');
      void fetchAndUpdate(videoId, titleP, thumbnailImg);
    });

    // Current video
    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`;
    }
})();
gBasil commented 1 year ago

I've modified Ajay's script again.

  1. I've added a simple polyfill for the fetch API that uses a GreaseMonkey API, which should get around CSP issues.
  2. I've added a function for converting Blobs to data URLs, as the blob: URLs from URL.createObjectURL can be blocked by CSP.
// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.2
// @description  Adds support for DeArrow in Invidious
// @author       You
// @match        https://yewtu.be/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// @author       Ajay
// ==/UserScript==

(async function() {
    '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) {
        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);
      }
      {
        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
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    const thumbnailDivs = document.querySelectorAll('div.thumbnail');
    thumbnailDivs.forEach((div) => {
      const videoUrl = new URL(div.parentNode.href);
      const videoId = videoUrl.searchParams.get('v');
      const titleP = [...div.parentNode.querySelectorAll('p')][1];
      const thumbnailImg = div.querySelector('img.thumbnail');
      void fetchAndUpdate(videoId, titleP, thumbnailImg);
    });

    // Current video
    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`;
    }
})();

@aggarwalsushant This may fix your issue. Also, you have to change the URL in @match to the URL of whichever Invidious instance you're using.

solisinvictum commented 1 year ago

thanks! that works great.

aggarwalsushant commented 1 year ago

@ajayyy @gBasil worked great now. Thanks. 👏🏽 Obviously the counters in the Dearrow plugin won't rise as it's the ViolentMonkey doing the things now. Will this script work for Piped instances too?

aggarwalsushant commented 1 year ago

Seems doesn't work with piped for now.

ajayyy commented 1 year ago

For Piped

https://github.com/TeamPiped/Piped/issues/2575#issuecomment-1641269041

Hi, we now have DeArrow support in a few places - feed, trending, and related videos. It's disabled by default but can be enabled in the preferences.

It looks like it doesn't support random thumbnails

FireMasterK commented 1 year ago

Piped now has native support for DeArrow everywhere with https://github.com/TeamPiped/Piped/commit/9539d51126c0a53f10d084d3fdbc2c677fb6ac28.

It looks like it doesn't support random thumbnails

Is there any explanation on what this feature is supposed to do? The API documentation currently doesn't have any information on what the randomTime is supposed to mean. My guess is that it is a random number between 0 and 1, and has to be multiplied with the video duration, and that has to be used as a thumbnail?

I've got another question - what happens when DeArrow doesn't have a video in its database? Piped fetches DeArrow content using k-anonymity, and we have a lot of null responses because it can't find a video.

ajayyy commented 1 year ago

@FireMasterK

Correct, there is this part in the documentation

Random time and video duration are used if you want to fallback thumbnails to a screenshot from a random time. To make thumbnail caching possible, this random time is consistent and produced by the server. The random time value is a number between 0 and 1 and must be multiplied by the video duration. For convenience, the API returns the video duration if it is known, but for most videos the server does not know the video duration. When the API returns a video duration of 0 or null, you must either not use this feature, or obtain the video duration using another method. The browser extension will fallback to fetching the video duration using other methods to always be able to use the feature.

If you'd like to wait on the thumbnail generator supporting the 0 to 1 numbers directly (which I plan to do), so you don't have to deal with getting the video duraton yourself, you can track https://github.com/ajayyy/DeArrowThumbnailCache/issues/4

As for what to do about videos not in the request due to using k-anonymity requests, I'm not sure of a good solution. These numbers are generated using a seed random with the videoID as the seed. Specifically, they use the alea algorithm in https://github.com/davidbau/seedrandom/blob/released/lib/alea.js. When k-anonymity is enabled in the browser extension, it falls back to finding the randomTime value using this library on the client side when needed. I don't know a good solution for Piped though since it is not in JS, so cannot use that library. Maybe in this case it needs to fall back to an original thumbnail until a solution to that part is determined.

FireMasterK commented 1 year ago

I have ported an Alea implementation to Java. The random numbers generated from the seed don't seem to match however:

image

DeArrow: https://sponsor.ajay.app/api/branding?videoID=VyI-2c9KI1I

The randomTIme for this specific video is 0.16970540321403121

ajayyy commented 1 year ago

@FireMasterK nice!

check on a video without SponsorBlock segments (they are the ones that wouldn't appear in the hash based API). If there are SponsorBlock segments, then it uses those to not choose a timestamp in a segment

solisinvictum commented 1 year ago

@gBasil Script sadly stopped working today. Not working on Invidious anymore.

Minion3665 commented 1 year ago

@gBasil Script sadly stopped working today. Not working on Invidious anymore.

that is a shame... what invidious instance are you using?

solisinvictum commented 1 year ago

@gBasil Script sadly stopped working today. Not working on Invidious anymore.

that is a shame... what invidious instance are you using?

my own. but tested other instances (edited the script to instances url's). none of them working anymore.

gBasil commented 1 year ago

@gBasil Script sadly stopped working today. Not working on Invidious anymore.

that is a shame... what invidious instance are you using?

my own. but tested other instances (edited the script to instances url's). none of them working anymore.

Strange, works for me on my own instance. Try checking the console in the browser dev tools and look for error messages.

solisinvictum commented 1 year ago
grafik

This is what is in the console.

solisinvictum commented 1 year ago

I tried now with Chrome, Firefox and Chromium (i self using librewolf). On Windows and Linux the same behaviour: Not working.

(violentmonkey was the only one extension)

Minion3665 commented 1 year ago

I tried now with Chrome, Firefox and Chromium (i self using librewolf). On Windows and Linux the same behaviour: Not working.

(violentmonkey was the only one extension)

please may you try

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  Adds support for DeArrow in Invidious
// @match        https://yewtu.be/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Basil, Macic-Dev, Minion3665, Ajay
// ==/UserScript==

(async function() {
    '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) {
        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);
      }
      {
        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
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    const thumbnailDivs = document.querySelectorAll('div.thumbnail');
    thumbnailDivs.forEach((div) => {
      const videoUrl = new URL(div.parentNode.href ?? div.querySelector('a').href);
      const videoId = videoUrl.searchParams.get('v');
      const titleP = [...div.parentNode.querySelectorAll('p')][1];
      const thumbnailImg = div.querySelector('img.thumbnail');
      void fetchAndUpdate(videoId, titleP, thumbnailImg);
    });

    // Current video
    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`;
    }
})();

and tell me if it fixes the issue? Some instances appear to have their href placed differently (a version thing maybe?)

ghost commented 1 year ago

and tell me if it fixes the issue?

Not the same person you were talking to, but this version of the script worked somewhat for me on a small personal instance while the previous version didn't (only found this thread yesterday).

It replaces the thumbnails correctly when one is available, but for any video that it can't find a user thumbnail for it won't replace it back with the original, does the script not do that or is that something going wrong somewhere? I'm on Firefox on Linux, using Violentmonkey, but the same thing happens with Tampermonkey, even if I use a freshly installed Google Chrome. image

This is all that's in browser console image

gBasil commented 1 year ago

So the issue seems to be that sometimes the Dearrow server responds with a Cloudflare 502 error and just returns an HTML page, presumably because it gets ratelimited by YouTube. I've updated it so that it only replaces the thumbnail if it receives an image.

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.4
// @description  Adds support for DeArrow in Invidious
// @match        https://yewtu.be/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Basil, Macic-Dev, Minion3665, Ajay
// ==/UserScript==

(async function() {
    '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 blobToDataURL(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) {
        const cachedNewThumbnail = await cachedThumbnailAPIResponse.blob();
        const dataURL = await blobToDataURL(cachedNewThumbnail);
        if (dataURL.startsWith('data:image/')) {
          thumbnailElement.src = dataURL;
        }
      }

      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);
      }
      {
        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}`);
          const dataURL = await blobToDataURL(await votedThumbnailAPIResponse.blob());
          if (dataURL.startsWith('data:image/')) {
            thumbnailElement.src = dataURL;
          }
        }
      }
    }

    // Replaces the title of elements
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    const thumbnailDivs = document.querySelectorAll('div.thumbnail');
    thumbnailDivs.forEach((div) => {
      const videoUrl = new URL(div.parentNode.href ?? div.querySelector('a').href);
      const videoId = videoUrl.searchParams.get('v');
      const titleP = [...div.parentNode.querySelectorAll('p')][1];
      const thumbnailImg = div.querySelector('img.thumbnail');
      void fetchAndUpdate(videoId, titleP, thumbnailImg);
    });

    // Current video
    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`;
    }
})();
solisinvictum commented 1 year ago

Sry for the late response @gBasil

Your latest submitted script here, works again!

Great. Thanks!

Sommerwiesel commented 1 year ago

@gBasil One Problem with your userscript: The thumbnails for YouTube shorts is in portrait format and the enclosing container (div) changes to portrait as well, breaking the style of any feeds page. You should probably add a CSS rule with a max height or something like that.

Screenshot

![Screenshot](http://all-it.org/share/inv.png)

gBasil commented 1 year ago

@Sommerwiesel Could you give me a link to a page (i.e. a channel page) so that I can reproduce that? I block the vertical Shorts videos with uBlock Origin, so I haven't run into this myself.

Sommerwiesel commented 1 year ago

@gBasil It's not a problem on channel pages because on there, shorts are in a separate category (videos and shorts) and thus, the different height does not break the style. It only happens on the feeds, where videos and shorts are mixed together.

You can create an account on my instance if you'd like and test it with this channel in your subscription feeds, they release shorts regularly:

https://invidious.nerdvpn.de/channel/UCchBatdUMZoMfJ3rIzgV84g

Also, can you share your ublock rule for blocking those pesky shorts? I'd like to have that, too :D

gBasil commented 1 year ago

The issue seems to be that Invidious returns the YouTube Shorts thumbnails with the same ratio as non-Shorts videos, meaning that it has the padding around it. However, DeArrow returns the thumbnail in the mobile orientation. I think the best solution would be for @ajayyy to do something on DeArrow's end (potentially by adding an endpoint that adds padding to the sides of the images?) in order to not have it break if an Invidious instance has custom styling.

Also, can you share your ublock rule for blocking those pesky shorts? I'd like to have that, too :D

It unfortunately isn't very reliable and ends up blocking streams and other things sometimes, because it just blocks videos that don't have a length. I wish there was an option to hide them entirely that didn't rely on hacks like these. But here it is:

owo##.pure-u-1.pure-u-md-1-4:not(:has(.thumbnail .length))
BurntEmbers commented 10 months ago

The dearrow script no longer works, not even the latest version. Has anyone been updating this or not? With youtube's new hostility to adblockers and purposely making your CPU faster, I'd love to fully move over there now, and I have gotten way too used to dearrow decrapifying searches and titles.

gBasil commented 10 months ago

I've been running into issues as well. I was hoping there would be an official integration in DeArrow by now.

@ajayyy Is this planned down the line, and if so, do you have any estimate as to when?

ajayyy commented 10 months ago

The main issue would be integrating channel allowlists which uses a YouTube api to get the channel ID. I haven't thought of a good solution for that yet.

gBasil commented 10 months ago

The main issue would be integrating channel allowlists which uses a YouTube api to get the channel ID. I haven't thought of a good solution for that yet.

@ajayyy I'm not entirely sure which ID, but doesn't Invidious have the channel ID (in the old format, at least) in the channel link under the video?

image
gBasil commented 9 months ago

Hey all, I've quickly updated the userscript to support what I believe is the new Invidious HTML layout. If you had errors previously, this'll hopefully fix them.

Still hoping for an official integration though :P

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  Adds support for DeArrow in Invidious
// @match        https://yewtu.be/*
// @match        https://iv.ggtyler.dev/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// @author       Ajay
// ==/UserScript==

(async function() {
    '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) {
        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);
      }
      {
        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
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    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);
      }
    });

    // Current video
    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`;
    }
})();
codenyte commented 9 months ago

Consider publishing the userscript on Greasyfork

gBasil commented 9 months ago

Consider publishing the userscript on Greasyfork

@codenyte The issue is is that the userscript needs permissions to access all Invidious sites. I could theoretically write a detector and allow the userscript to access all sites, but I don't know of a good way to do the detection.

codenyte commented 9 months ago

Right, I completely forgot about this

shaedrich commented 6 months ago

Piped now has native support for DeArrow everywhere with TeamPiped/Piped@9539d51.

Well, for the frontend. However, as far as I know, one can't do any submissions yet. That would be quite helpful.

T3M1N4L commented 4 months ago
// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  Adds support for DeArrow in Invidious
// @match        https://yewtu.be/*
// @match        https://iv.ggtyler.dev/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// @author       Ajay
// ==/UserScript==

(async function() {
    '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) {
        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);
      }
      {
        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
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    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);
      }
    });

    // Current video
    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`;
    }
})();

Returns this (yewtube) image

filip2cz commented 4 months ago

Maybe author of this script should make repo for it, so we can easily find latest version and pull request our changes?

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.3
// @description  Adds support for DeArrow in Invidious
// @match        https://yewtu.be/*
// @match        https://iv.ggtyler.dev/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// @author       Ajay
// ==/UserScript==

(async function() {
    '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) {
        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);
      }
      {
        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
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    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);
      }
    });

    // Current video
    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`;
    }
})();

Returns this (yewtube) image

gBasil commented 4 months ago

Maybe author of this script should make repo for it, so we can easily find latest version and pull request our changes?

I'm not sure who the original author is, but maybe Ajay could? But it would be ideal if this was just integrated directly into DeArrow (I'm surprised it hasn't been added yet).

shaedrich commented 4 months ago

Piped now has native support for DeArrow everywhere with TeamPiped/Piped@9539d51.

Well, for the frontend. However, as far as I know, one can't do any submissions yet. That would be quite helpful.

Are there any plans to implement that?

Euphoriyy commented 3 months ago

This update should fix any thumbnail-related issues:

// ==UserScript==
// @name         DeArrow for Invidious
// @namespace    http://tampermonkey.net/
// @version      0.2.4
// @description  Adds support for DeArrow in Invidious
// @match        https://yewtu.be/*
// @match        https://iv.ggtyler.dev/*
// @icon         https://dearrow.ajay.app/logo.svg
// @grant        GM.xmlHttpRequest
// @author       Macic-Dev
// @author       Minion3665
// @author       Basil
// @author       Ajay
// @author       Euphoriyy
// ==/UserScript==

(async function() {
    '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
    function replaceTitle(element, text) {
      if (element.nodeName === 'H1') {
        for (const child of element.childNodes) {
          if (child.nodeName !== '#text') continue;

          child.textContent = text;
          break;
        }
      } else {
        element.textContent = text;
      }
    }

    // Videos on page
    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);
      }
    });

    // Current video
    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`;
    }
})();
Kladki commented 2 months ago

Thanks for the script! One small area that is not covered though: the playlist videos while watching a video: image

If that could be covered, then I would have no issues with the script other than the lack of submission.

Kladki commented 1 month ago

I have tried fixing this myself, and I have got it to work, with the catch of it only being when manually activated via context menu. This is because that playlist sidebar is loaded with javascript after the initial page load, meaning that the script is already finished executing before that sidebar loads in, preventing the thumbnails and titles from changing. Here is the script in case anyone finds a solution to this.