// ==UserScript==
// @name Invidious Local Subscriptions
// @author mthsk
// @homepage https://codeberg.org/mthsk/userscripts/src/branch/master/inv-local-subscriptions
// @match *://invidio.xamh.de/*
// @match *://vid.puffyan.us/*
// @match *://watch.thekitty.zone/*
// @match *://y.com.sb/*
// @match *://yewtu.be/*
// @match *://youtube.076.ne.jp/*
// @match *://inv.*.*/*
// @match *://invidious.*/*
// @exclude *://invidious.dhusch.de/*
// @exclude *://invidious.nerdvpn.de/*
// @exclude *://invidious.weblibre.org/*
// @version 2023.01
// @description Implements local subscriptions on Invidious.
// @run-at document-end
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.xmlHttpRequest
// @license AGPL-3.0-or-later
// @namespace https://gf.qytechs.cn/users/751327
// ==/UserScript==
/**
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
(async function() {
"use strict";
let subscriptions = await GM.getValue("subscriptions") || [];
let settings = await GM.getValue("settings") || {redirect: false};
if (location.pathname.toLowerCase().startsWith("/feed/"))
{
if (location.hash.toLowerCase() === "#invlocal") {
document.getElementById("contents").querySelector('div[class="pure-g"]').innerHTML = '\<center id="invlocal-loading" style="letter-spacing: 0 !important;">Fetching subscriptions...</center>';
let feed = await getSubscriptionFeed(false);
displaySubscriptionFeed(feed);
let st = 40;
addEventListener('scroll', function() {
if (window.innerHeight + window.pageYOffset >= document.body.offsetHeight && !document.getElementById("invlocal-loading")) {
displaySubscriptionFeed(feed, st);
st = st + 40;
}});
document.getElementsByClassName("feed-menu")[0].innerHTML = document.getElementsByClassName("feed-menu")[0].innerHTML + "\<a id=\"invlocal-refresh\" href=\"javascript:void(0);\" class=\"feed-menu-item pure-menu-heading\">Refresh Subscriptions</a>";
document.getElementById("invlocal-refresh").addEventListener('click', async function (e) {
if (!e.target.hasAttribute('disabled'))
{
e.target.setAttribute('disabled', '');
document.getElementById("contents").querySelector('div[class="pure-g"]').innerHTML = '\<center id="invlocal-loading" style="letter-spacing: 0 !important;">Fetching subscriptions...</center>';
feed = await getSubscriptionFeed(true);
displaySubscriptionFeed(feed);
st = 40;
e.target.removeAttribute('disabled');
}
});
}
else {
document.getElementsByClassName("feed-menu")[0].innerHTML = document.getElementsByClassName("feed-menu")[0].innerHTML + "\<a href=\"/feed/#invlocal\" class=\"feed-menu-item pure-menu-heading\">Local Subscriptions</a>"
}
}
else if (location.pathname.toLowerCase().startsWith("/channel/") || location.pathname.toLowerCase() === "/watch")
{
const invsubbutton = document.getElementById("subscribe");
const localsubbutton = invsubbutton.cloneNode(true);
localsubbutton.id = "localsubscribe";
localsubbutton.removeAttribute("href");
invsubbutton.parentElement.appendChild(localsubbutton);
let chid = "";
let chname = "";
if (location.pathname.toLowerCase().startsWith("/channel/"))
{
chid = location.pathname.split('/')[2];
chname = document.body.querySelector('div[class="channel-profile"] span').textContent.trim();
}
else
{
chid = document.getElementById("published-date").parentElement.querySelector('a[href^="/channel/"]').getAttribute("href").split('/')[2];
chname = document.getElementById("channel-name").textContent.trim();
}
if (subscriptions.length > 0 && subscriptions.some(e => e.id === chid))
localsubbutton.innerHTML = "\<b>Unsubscribe Locally</b>";
else
localsubbutton.innerHTML = "\<b>Subscribe Locally</b>";
localsubbutton.addEventListener("click", async function(ev) {
if (subscriptions.length > 0 && subscriptions.some(e => e.id === chid)) { //unsubscribe if already subscribed
if (!confirm("Do you really want to unsubscribe from \"" + chname + "\"?"))
return;
let x = 0;
subscriptions.forEach(function(e) {
if (e.id === chid)
{
subscriptions.splice(x, 1);
ev.target.innerHTML = "\<b>Subscribe Locally</b>";
}
x++;
});
}
else // subscribe if not
{
subscriptions.push({id: chid, name: chname})
ev.target.innerHTML = "\<b>Unsubscribe Locally</b>";
}
console.log(subscriptions);
await GM.setValue('subscriptions', subscriptions);
});
}
else if (location.pathname.toLowerCase() === "/preferences")
{
let fieldset = document.body.getElementsByTagName("fieldset");
fieldset = fieldset[fieldset.length - 1];
const savebtn = fieldset.getElementsByTagName("button")[0];
const nulegend = document.createElement('legend');
nulegend.textContent = "Local Subscribe Preferences";
const nusettings = document.createElement('div');
nusettings.setAttribute("class", "pure-control-group");
nusettings.innerHTML = '\<div class="pure-control-group"><div class="pure-control-group"><label for="invlocal_redirect">Redirect from the invidious home page to local subscriptions page: </label><input id="invlocal_redirect" type="checkbox"></div><div class="pure-control-group"><label for="invlocal_import">Import subscriptions: </label><a id="invlocal_import" href="javascript:void(0);">Import...</a></div><div class="pure-control-group"><label for="invlocal_export">Export subscriptions: </label><a id="invlocal_export" href="javascript:void(0);">Export...</a></div></div>';
fieldset.insertBefore(nulegend, savebtn.parentElement);
fieldset.insertBefore(nusettings, savebtn.parentElement);
document.getElementById("invlocal_redirect").checked = settings.redirect;
document.getElementById("invlocal_import").addEventListener("click", (ev) => {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", ".json");
input.addEventListener("change", async function(e) {
try {
const file = await input.files[0].text();
const newpipe_subs = JSON.parse(file);
const nusubs = [];
newpipe_subs.subscriptions.forEach((i) => {
if (i.service_id === 0) {
const chanurl = new URL(i.url);
const chanid = chanurl.pathname.split("/channel/").pop().split('/')[0];
nusubs.push({id: chanid, name: i.name});
}
});
subscriptions = nusubs;
await GM.setValue('subscriptions', subscriptions);
await GM.setValue('feed', {last: 0, feed: []});
alert("Subscriptions imported successfully!");
}
catch (ex) { alert("File is corrupted or not supported."); }
});
input.click();
});
document.getElementById("invlocal_export").addEventListener("click", (ev) => {
const date = new Date();
const YYYYMMDDHHmm = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) + ("0" + date.getHours() ).slice(-2) + ("0" + date.getMinutes()).slice(-2);
const newpipe_subs = {app_version: "0.24.0", app_version_int: 990, subscriptions: []};
subscriptions.forEach((e) => {
newpipe_subs.subscriptions.push({service_id: 0, url: "https://www.youtube.com/channel/" + e.id, name: e.name});
});
const a = document.createElement("a");
const file = new Blob([JSON.stringify(newpipe_subs)], {type: "application/json"});
a.href = URL.createObjectURL(file);
a.download = "newpipe_subscriptions_" + YYYYMMDDHHmm + ".json";
a.click();
});
savebtn.addEventListener("click", (ev) => {
settings.redirect = document.getElementById("invlocal_redirect").checked;
GM.setValue('settings', settings);
});
}
if (settings.redirect && location.pathname === "/")
location.replace(location.protocol + "//" + location.hostname + "/feed/#invlocal");
else if (settings.redirect)
document.body.querySelectorAll('a[href="/"]').forEach((el) => el.setAttribute("href","/feed/#invlocal"));
function roundViews(views) {
let rounded = 0;
let mode = "";
if (views >= 1000000000) {
rounded = views / 1000000000;
mode = "B";
}
else if (views >= 1000000) {
rounded = views / 1000000;
mode = "M"
}
else if (views >= 1000) {
rounded = views / 1000;
mode = "K"
}
else if (views > 1) {
return views + " views";
}
else {
return views + " view";
}
if (rounded < 10)
rounded = Math.floor(rounded * 10) / 10;
else
rounded = Math.floor(rounded);
return rounded + mode + " views";
}
function msToHumanTime(ms) {
const seconds = (ms / 1000);
let tvalue = 0;
let human = "";
if (seconds >= 31536000) {
tvalue = Math.floor(seconds / 31536000);
human = "year";
}
else if (seconds >= 2628000) {
tvalue = Math.floor(seconds / 2628000);
human = "month";
}
else if (seconds >= 604800) {
tvalue = Math.floor(seconds / 604800);
human = "week";
}
else if (seconds >= 86400) {
tvalue = Math.floor(seconds / 86400);
human = "day";
}
else if (seconds >= 3600) {
tvalue = Math.floor(seconds / 3600);
human = "hour";
}
else if (seconds >= 60) {
tvalue = Math.floor(seconds / 60);
human = "minute";
}
else {
tvalue = Math.floor(seconds);
human = "second";
}
if (tvalue > 1)
return tvalue + ' ' + human + "s ago";
else
return tvalue + ' ' + human + " ago";
}
function displaySubscriptionFeed(feed,start = 0) {
const container = document.getElementById("contents").querySelector('div[class="pure-g"]');
if (!container || start >= feed.length) {
const elloading = document.getElementById('invlocal-loading');
if (!!elloading && feed.length === 0)
elloading.textContent = "No subscriptions to fetch.";
return;
}
if (start === 0)
container.innerHTML = "";
let finish = start + 39;
if (finish > (feed.length - 1))
finish = feed.length - 1;
for (let x = start; x <= finish; x++)
{
const date = new Date(0);
date.setSeconds(feed[x].lengthSeconds);
container.innerHTML = container.innerHTML + '\<div class="pure-u-1 pure-u-md-1-4"><div class="h-box"><a style="width:100%" href="/watch?v=' + feed[x].videoId + '"><div class="thumbnail"><img tabindex="-1" class="thumbnail" src="/vi/' + feed[x].videoId + '/mqdefault.jpg"/> <p class="length">' + date.toISOString().substring(11, 19).split("00:").pop() + '\</p></div><p dir="auto">' + feed[x].title + '\</p></a><div class="video-card-row flexible"><div class="flex-left"><a href="/channel/' + feed[x].authorId + '"><p class="channel-name" dir="auto">' + feed[x].author + '\</p> </a></div> <div class="flex-right"><div class="icon-buttons"><a title="Watch on YouTube" href="https://www.youtube.com/watch?v=' + feed[x].videoId + '"><i class="icon ion-logo-youtube"></i></a> <a title="Audio mode" href="/watch?v=' + feed[x].videoId + '&listen=1"><i class="icon ion-md-headset"></i></a> <a title="Switch Invidious Instance" href="https://redirect.invidious.io/watch?v=' + feed[x].videoId + '"><i class="icon ion-md-jet"></i></a></div></div></div> <div class="video-card-row flexible"><div class="flex-left"><p class="video-data" dir="auto">Shared ' + msToHumanTime(Date.now() - (feed[x].published * 1000)) + '\</p></div><div class="flex-right"><p class="video-data" dir="auto">' + roundViews(feed[x].viewCount) + '\</p></div></div></div></div>'
}
}
async function getJson(url) {
const resp = await (function() {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url: url,
headers: {
'Accept': 'application/json'
},
onload: resolve,
onabort: reject,
onerror: reject
});
});
})();
try {
return JSON.parse(resp.responseText);
} catch (e) { return { error: true }; }
}
async function getSubscriptionFeed(forced = false) {
let feed = await GM.getValue('feed');
if (forced || !feed || feed.last < (Date.now() - 1800000))
{
let hadError = false;
feed = [];
const instances = [];
let jsonInstances = await getJson("https://api.invidious.io/instances.json");
if ((Object.keys(jsonInstances).length === 0 && jsonInstances.construct) || (jsonInstances.hasOwnProperty("error") && jsonInstances.error))
return;
jsonInstances.forEach(function(e) {
if (e[1].type === "https" && e[1].api)
{
instances.push(e[0]);
}
});
console.log(instances);
if (instances.length <= 0)
return;
for (let x = 0; x < subscriptions.length; x++)
{
document.getElementById('invlocal-loading').textContent = "Fetching channel " + (x + 1) + " out of " + subscriptions.length;
let response = await getJson("https://" + instances[Math.floor(Math.random()*instances.length)] + "/api/v1/channels/videos/" + subscriptions[x].id + "?fields=title,videoId,author,authorId,viewCount,published,lengthSeconds,videos");
if (response.hasOwnProperty("error"))
{
hadError = true;
continue;
}
else if (response.hasOwnProperty("videos"))
response = response.videos;
feed = feed.concat(response);
}
feed.sort((a, b) => b.published - a.published);
if (!hadError)
await GM.setValue('feed', {last: Date.now(), feed: feed});
else
await GM.setValue('feed', {last: 0, feed: feed});
return feed;
}
else
return feed.feed;
}
})();