Invidious Local Subscriptions

Implements local subscriptions on Invidious.

当前为 2023-06-12 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Invidious Local Subscriptions
  3. // @author mthsk
  4. // @homepage https://codeberg.org/mthsk/userscripts/src/branch/master/inv-local-subscriptions
  5. // @match *://invidio.xamh.de/*
  6. // @match *://vid.puffyan.us/*
  7. // @match *://watch.thekitty.zone/*
  8. // @match *://y.com.sb/*
  9. // @match *://yewtu.be/*
  10. // @match *://youtube.076.ne.jp/*
  11. // @match *://inv.*.*/*
  12. // @match *://invidious.*/*
  13. // @version 2023.06
  14. // @description Implements local subscriptions on Invidious.
  15. // @run-at document-end
  16. // @grant GM.getValue
  17. // @grant GM.setValue
  18. // @grant GM.xmlHttpRequest
  19. // @license AGPL-3.0-or-later
  20. // @namespace https://greasyfork.org/users/751327
  21. // ==/UserScript==
  22. /**
  23. * This program is free software: you can redistribute it and/or modify
  24. * it under the terms of the GNU Affero General Public License as
  25. * published by the Free Software Foundation, either version 3 of the
  26. * License, or (at your option) any later version.
  27. *
  28. * This program is distributed in the hope that it will be useful,
  29. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  30. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  31. * GNU Affero General Public License for more details.
  32. *
  33. * You should have received a copy of the GNU Affero General Public License
  34. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  35. */
  36. (async function() {
  37. "use strict";
  38. let subscriptions = await GM.getValue("subscriptions") || [];
  39. let settings = await GM.getValue("settings") || {redirect: false, odysee: false, usepiped: true, pipedinstance: "https://pipedapi-libre.kavin.rocks"};
  40.  
  41. if (settings.redirect && location.pathname === "/")
  42. location.replace(location.protocol + "//" + location.hostname + "/search?q=" + makeid((Math.floor(Math.random() * 16) + 16)) + "#invlocal");
  43. else if (settings.redirect)
  44. document.body.querySelectorAll('a[href="/"]').forEach((el) => el.setAttribute("href","/search?q=" + makeid((Math.floor(Math.random() * 16) + 16)) + "#invlocal"));
  45.  
  46. if (location.pathname.toLowerCase() === "/search" && location.search.toLowerCase().startsWith("?q=") && !location.search.includes('&') && location.hash.toLowerCase() === "#invlocal")
  47. {
  48. const navbar = document.createElement("div");
  49. navbar.classList.add("feed-menu");
  50. navbar.innerHTML = '<a href="/feed/popular" class="feed-menu-item pure-menu-heading">Popular</a><a href="/feed/trending" class="feed-menu-item pure-menu-heading">Trending</a><a id="invlocal-refresh" href="javascript:void(0);" class="feed-menu-item pure-menu-heading">Refresh Subscriptions</a>';
  51. const filters = document.getElementById("filters");
  52. filters.parentElement.replaceChild(navbar, filters);
  53. document.getElementById("contents").querySelector('div[class="pure-g"]').innerHTML = '\<center id="invlocal-loading" style="letter-spacing: 0 !important;">Fetching subscriptions...</center>';
  54. document.getElementById("contents").querySelectorAll('div > a[href*="&page="]').forEach(el => el.parentElement.remove());
  55. document.getElementById("contents").querySelectorAll("div.no-results-error").forEach(el => { el.remove(); document.getElementById("contents").querySelector("footer > div.pure-g").parentElement.replaceWith(document.getElementById("contents").querySelector("footer > div.pure-g")); });
  56. document.getElementById("contents").getElementsByTagName("hr")[0].remove();
  57. document.getElementById("searchbox").value = "";
  58. document.title = "Local Subscription Feed - Invidious";
  59. let feed;
  60. if (settings.hasOwnProperty("usepiped") && settings.usepiped)
  61. feed = await getPipedSubscriptionFeed(settings.pipedinstance);
  62. else
  63. feed = await getSubscriptionFeed(false);
  64. displaySubscriptionFeed(feed);
  65. let st = 40;
  66. addEventListener('scroll', function() {
  67. if (window.innerHeight + window.pageYOffset >= document.body.offsetHeight && !document.getElementById("invlocal-loading")) {
  68. displaySubscriptionFeed(feed, st);
  69. st = st + 40;
  70. }});
  71.  
  72. document.getElementById("invlocal-refresh").addEventListener('click', async function (e) {
  73. if (!e.target.hasAttribute('disabled'))
  74. {
  75. e.target.setAttribute('disabled', '');
  76. document.getElementById("contents").querySelector('div[class="pure-g"]').innerHTML = '\<center id="invlocal-loading" style="letter-spacing: 0 !important;">Fetching subscriptions...</center>';
  77.  
  78. if (settings.hasOwnProperty("usepiped") && settings.usepiped)
  79. feed = await getPipedSubscriptionFeed(settings.pipedinstance);
  80. else
  81. feed = await getSubscriptionFeed(true);
  82.  
  83. displaySubscriptionFeed(feed);
  84. st = 40;
  85. e.target.removeAttribute('disabled');
  86. }
  87. });
  88. }
  89. else if (location.pathname.toLowerCase().startsWith("/feed/"))
  90. {
  91. document.getElementsByClassName("feed-menu")[0].innerHTML = document.getElementsByClassName("feed-menu")[0].innerHTML + "\<a href=\"/search?q=" + makeid((Math.floor(Math.random() * 16) + 16)) + "#invlocal\" class=\"feed-menu-item pure-menu-heading\">Local Subscriptions</a>"
  92. }
  93. else if (location.pathname.toLowerCase().startsWith("/channel/") || location.pathname.toLowerCase() === "/watch")
  94. {
  95. const invsubbutton = document.getElementById("subscribe");
  96. const localsubbutton = invsubbutton.cloneNode(true);
  97. localsubbutton.id = "localsubscribe";
  98. localsubbutton.removeAttribute("href");
  99. invsubbutton.parentElement.appendChild(localsubbutton);
  100.  
  101. let chid = "";
  102. let chname = "";
  103. if (location.pathname.toLowerCase().startsWith("/channel/"))
  104. {
  105. chid = location.pathname.split('/')[2];
  106. chname = document.body.querySelector('div[class="channel-profile"] span').textContent.trim();
  107. }
  108. else
  109. {
  110. chid = document.getElementById("published-date").parentElement.querySelector('a[href^="/channel/"]').getAttribute("href").split('/')[2];
  111. chname = document.getElementById("channel-name").textContent.trim();
  112. }
  113.  
  114. if (subscriptions.length > 0 && subscriptions.some(e => e.id === chid))
  115. localsubbutton.innerHTML = "\<b>Unsubscribe Locally</b>";
  116. else
  117. localsubbutton.innerHTML = "\<b>Subscribe Locally</b>";
  118.  
  119. localsubbutton.addEventListener("click", async function(ev) {
  120. if (subscriptions.length > 0 && subscriptions.some(e => e.id === chid)) { //unsubscribe if already subscribed
  121. if (!confirm("Do you really want to unsubscribe from \"" + chname + "\"?"))
  122. return;
  123.  
  124. let x = 0;
  125. subscriptions.forEach(function(e) {
  126. if (e.id === chid)
  127. {
  128. subscriptions.splice(x, 1);
  129. ev.target.innerHTML = "\<b>Subscribe Locally</b>";
  130. }
  131. x++;
  132. });
  133. }
  134. else // subscribe if not
  135. {
  136. subscriptions.push({id: chid, name: chname})
  137. ev.target.innerHTML = "\<b>Unsubscribe Locally</b>";
  138. }
  139. console.log(subscriptions);
  140. await GM.setValue('subscriptions', subscriptions);
  141. });
  142.  
  143. if (location.pathname.toLowerCase() === "/watch")
  144. {
  145. window.setInterval(getSubscriptionFeed(false, true), 300000);
  146. }
  147. }
  148. else if (location.pathname.toLowerCase() === "/preferences")
  149. {
  150. let fieldset = document.body.getElementsByTagName("fieldset");
  151. fieldset = fieldset[fieldset.length - 1];
  152. const savebtn = fieldset.getElementsByTagName("button")[0];
  153. const nulegend = document.createElement('legend');
  154. nulegend.textContent = "Local Subscribe Preferences";
  155. const nusettings = document.createElement('div');
  156. nusettings.setAttribute("class", "pure-control-group");
  157. 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_odysee">Display a "Watch on Odysee" button for videos that were synced to Odysee: </label><input id="invlocal_odysee" type="checkbox"></div><div class="pure-control-group"><label for="invlocal_usepiped">Fetch Subscriptions Through Piped: </label><input id="invlocal_usepiped" type="checkbox"></div><div class="pure-control-group"><label for="invlocal_pipedinstance">Database Instance:</label><input id="invlocal_pipedinstance" type="text"></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>';
  158. fieldset.insertBefore(nulegend, savebtn.parentElement);
  159. fieldset.insertBefore(nusettings, savebtn.parentElement);
  160.  
  161. document.getElementById("invlocal_redirect").checked = settings.redirect;
  162. document.getElementById("invlocal_odysee").checked = settings.odysee || false;
  163. document.getElementById("invlocal_usepiped").checked = settings.usepiped || false;
  164. document.getElementById("invlocal_pipedinstance").value = settings.pipedinstance || "https://pipedapi-libre.kavin.rocks";
  165.  
  166. document.getElementById("invlocal_import").addEventListener("click", (ev) => {
  167. const input = document.createElement("input");
  168. input.setAttribute("type", "file");
  169. input.setAttribute("accept", ".json");
  170. input.addEventListener("change", async function(e) {
  171. try {
  172. const file = await input.files[0].text();
  173. const newpipe_subs = JSON.parse(file);
  174. const nusubs = [];
  175. newpipe_subs.subscriptions.forEach((i) => {
  176. if (i.service_id === 0) {
  177. const chanurl = new URL(i.url);
  178. const chanid = chanurl.pathname.split("/channel/").pop().split('/')[0];
  179. nusubs.push({id: chanid, name: i.name});
  180. }
  181. });
  182. subscriptions = nusubs;
  183. await GM.setValue('subscriptions', subscriptions);
  184. await GM.setValue('feed', {last: 0, feed: []});
  185. alert("Subscriptions imported successfully!");
  186. }
  187. catch (ex) { alert("File is corrupted or not supported."); }
  188. });
  189. input.click();
  190. });
  191.  
  192. document.getElementById("invlocal_export").addEventListener("click", (ev) => {
  193. const date = new Date();
  194. 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);
  195. const newpipe_subs = {app_version: "0.24.0", app_version_int: 990, subscriptions: []};
  196. subscriptions.forEach((e) => {
  197. newpipe_subs.subscriptions.push({service_id: 0, url: "https://www.youtube.com/channel/" + e.id, name: e.name});
  198. });
  199. const a = document.createElement("a");
  200. const file = new Blob([JSON.stringify(newpipe_subs)], {type: "application/json"});
  201. a.href = URL.createObjectURL(file);
  202. a.download = "newpipe_subscriptions_" + YYYYMMDDHHmm + ".json";
  203. a.click();
  204. });
  205.  
  206. savebtn.addEventListener("click", (ev) => {
  207. settings.redirect = document.getElementById("invlocal_redirect").checked;
  208. settings.odysee = document.getElementById("invlocal_odysee").checked;
  209. settings.usepiped = document.getElementById("invlocal_usepiped").checked;
  210. if (document.getElementById("invlocal_pipedinstance").value.trim() != "")
  211. settings.pipedinstance = document.getElementById("invlocal_pipedinstance").value.trim();
  212. GM.setValue('settings', settings);
  213. });
  214. }
  215.  
  216. function makeid(length) {
  217. let result = '';
  218. const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
  219. const charactersLength = characters.length;
  220. let counter = 0;
  221. while (counter < length) {
  222. result += characters.charAt(Math.floor(Math.random() * charactersLength));
  223. counter += 1;
  224. }
  225. return result;
  226. }
  227.  
  228. function roundViews(views) {
  229. let rounded = 0;
  230. let mode = "";
  231.  
  232. if (views >= 1000000000) {
  233. rounded = views / 1000000000;
  234. mode = "B";
  235. }
  236. else if (views >= 1000000) {
  237. rounded = views / 1000000;
  238. mode = "M"
  239. }
  240. else if (views >= 1000) {
  241. rounded = views / 1000;
  242. mode = "K"
  243. }
  244. else if (views > 1) {
  245. return views + " views";
  246. }
  247. else {
  248. return views + " view";
  249. }
  250.  
  251. if (rounded < 10)
  252. rounded = Math.floor(rounded * 10) / 10;
  253. else
  254. rounded = Math.floor(rounded);
  255.  
  256. return rounded + mode + " views";
  257. }
  258.  
  259. function msToHumanTime(ms) {
  260. const seconds = (ms / 1000);
  261. let tvalue = 0;
  262. let human = "";
  263.  
  264. if (seconds >= 31536000) {
  265. tvalue = Math.floor(seconds / 31536000);
  266. human = "year";
  267. }
  268. else if (seconds >= 2628000) {
  269. tvalue = Math.floor(seconds / 2628000);
  270. human = "month";
  271. }
  272. else if (seconds >= 604800) {
  273. tvalue = Math.floor(seconds / 604800);
  274. human = "week";
  275. }
  276. else if (seconds >= 86400) {
  277. tvalue = Math.floor(seconds / 86400);
  278. human = "day";
  279. }
  280. else if (seconds >= 3600) {
  281. tvalue = Math.floor(seconds / 3600);
  282. human = "hour";
  283. }
  284. else if (seconds >= 60) {
  285. tvalue = Math.floor(seconds / 60);
  286. human = "minute";
  287. }
  288. else {
  289. tvalue = Math.floor(seconds);
  290. human = "second";
  291. }
  292.  
  293. if (tvalue > 1)
  294. return tvalue + ' ' + human + "s ago";
  295. else
  296. return tvalue + ' ' + human + " ago";
  297. }
  298.  
  299. function durationString(scs) {
  300. const durDate = new Date(0);
  301. durDate.setSeconds(scs);
  302. const durHour = Math.floor(durDate.getTime() / 1000 / 60 / 60);
  303. const durMin = durDate.getUTCMinutes();
  304. const durSec = durDate.getUTCSeconds();
  305.  
  306. return (durHour > 0 ? durHour + ':' : '') + (durHour === 0 || durMin > 9 ? durMin : '0' + durMin) + ':' + (durSec > 9 ? durSec : '0' + durSec);
  307. }
  308.  
  309. async function displaySubscriptionFeed(feed,start = 0) {
  310. const container = document.getElementById("contents").querySelector('div[class="pure-g"]');
  311.  
  312. if (!container || start >= feed.length) {
  313. const elloading = document.getElementById('invlocal-loading');
  314. if (!!elloading && feed.length === 0)
  315. elloading.textContent = "No subscriptions to fetch.";
  316. return;
  317. }
  318.  
  319. if (start === 0)
  320. container.innerHTML = "";
  321.  
  322. let finish = start + 39;
  323. if (finish > (feed.length - 1))
  324. finish = feed.length - 1;
  325.  
  326. const vidIds = [];
  327. for (let x = start; x <= finish; x++)
  328. {
  329. vidIds.push(feed[x].videoId);
  330. 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">' + durationString(feed[x].lengthSeconds) + '\</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 class="invlocal-odysee" style="visibility: hidden !important;" title="Watch on Odysee" href="' + feed[x].videoId + '"><i class="icon ion-md-rocket"></i></a> <a title="Discuss on Reddit" href="https://www.reddit.com/search?q=url%3A%22' + feed[x].videoId + '%22+AND+%28site%3Ayoutube.com+OR+site%3Ayoutu.be+OR+site%3Ayoutube-nocookie.com%29&amp;restrict_sr=&amp;sort=top&amp;t=all"><i class="icon ion-logo-reddit"></i></a> <a title="Audio mode" href="/watch?v=' + feed[x].videoId + '&amp;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>'
  331. }
  332. let odyUrls = {};
  333. if (!!settings.odysee && settings.odysee) {
  334. odyUrls = await getJson("https://api.odysee.com/yt/resolve?video_ids=" + encodeURIComponent(vidIds.join(',')), 0);
  335. odyUrls = (!!odyUrls.data && !!odyUrls.data.videos ? odyUrls.data.videos : {});
  336. }
  337. const odyseeEls = document.body.querySelectorAll("a.invlocal-odysee[href]");
  338. odyseeEls.forEach((ody) => {
  339. let vId = ody.getAttribute('href');
  340. if (!!settings.odysee && settings.odysee && odyUrls.hasOwnProperty(vId) && odyUrls[vId] !== null)
  341. {
  342. ody.href = "https://odysee.com/" + odyUrls[vId].replaceAll('#',':');
  343. ody.removeAttribute("class");
  344. ody.removeAttribute("style");
  345. }
  346. else {
  347. ody.remove();
  348. }
  349. });
  350. }
  351.  
  352. async function getJson(url,timeout=10000) {
  353. console.log("Connecting to " + url);
  354. try {
  355. const resp = await (function() {
  356. return new Promise((resolve, reject) => {
  357. GM.xmlHttpRequest({
  358. method: 'GET',
  359. url: url,
  360. timeout: timeout,
  361. headers: {
  362. 'Accept': 'application/json'
  363. },
  364. onload: resolve,
  365. onabort: reject,
  366. onerror: reject,
  367. ontimeout: reject
  368. });
  369. });
  370. })();
  371. return JSON.parse(resp.responseText);
  372. } catch (e) { return { error: true }; }
  373. }
  374.  
  375. async function getSubscriptionFeed(forced = false, background = false) {
  376. let feed = await GM.getValue('feed');
  377. if (forced || !feed || feed.last < (Date.now() - 1800000))
  378. {
  379. let hadError = false;
  380. feed = [];
  381. const instances = [];
  382. let jsonInstances = await getJson("https://api.invidious.io/instances.json");
  383.  
  384. if ((Object.keys(jsonInstances).length === 0 && jsonInstances.construct) || (jsonInstances.hasOwnProperty("error") && jsonInstances.error))
  385. return;
  386.  
  387. jsonInstances.forEach(function(e) {
  388. if (e[1].type === "https" && e[1].api)
  389. {
  390. instances.push(e[0]);
  391. }
  392. });
  393. console.log(instances);
  394.  
  395. if (instances.length <= 0)
  396. return;
  397.  
  398. for (let x = 0; x < subscriptions.length; x++)
  399. {
  400. if (!!document.getElementById('invlocal-loading'))
  401. document.getElementById('invlocal-loading').textContent = "Fetching channel " + (x + 1) + " out of " + subscriptions.length;
  402.  
  403. 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");
  404.  
  405. if (response.hasOwnProperty("error") && background)
  406. return;
  407. else if (response.hasOwnProperty("error"))
  408. {
  409. hadError = true;
  410. continue;
  411. }
  412. else if (response.hasOwnProperty("videos"))
  413. response = response.videos;
  414.  
  415. let playlist = await getJson("https://" + instances[Math.floor(Math.random()*instances.length)] + "/api/v1/playlists/" + subscriptions[x].id + "?fields=videos(title,videoId,author,authorId,lengthSeconds)");
  416. if (playlist.hasOwnProperty("videos"))
  417. {
  418. playlist = playlist.videos;
  419.  
  420. if (playlist.length > 10)
  421. playlist = playlist.slice(0, 10);
  422.  
  423. let plv = 0;
  424. playlist.forEach(async function(vid) {
  425. if (!response.some(e => e.videoId === vid.videoId))
  426. {
  427. let vidData = await getJson("https://" + instances[Math.floor(Math.random()*instances.length)] + "/api/v1/videos/" + vid.videoId + "?fields=viewCount,published");
  428. if (!vidData.hasOwnProperty("error"))
  429. {
  430. vid.viewCount = vidData.viewCount;
  431. vid.published = vidData.published;
  432. feed.push(vid);
  433. }
  434. }
  435. plv++;
  436. });
  437. }
  438.  
  439. feed = feed.concat(response);
  440. }
  441.  
  442. feed.sort((a, b) => b.published - a.published);
  443.  
  444. if (!hadError)
  445. await GM.setValue('feed', {last: Date.now(), feed: feed});
  446. else
  447. await GM.setValue('feed', {last: 0, feed: feed});
  448.  
  449. return feed;
  450. }
  451. else
  452. return feed.feed;
  453. }
  454.  
  455. async function getPipedSubscriptionFeed(api = "pipedapi.kavin.rocks") {
  456. let feed = [];
  457. const channels = [];
  458. subscriptions.forEach(ch => channels.push(ch.id));
  459. const response = await getJson(api + "/feed/unauthenticated?channels=" + encodeURIComponent(channels.join(',')), 0);
  460.  
  461. if (response.hasOwnProperty("error"))
  462. {
  463. feed = await GM.getValue('feed');
  464.  
  465. if (!!feed && !!feed.feed)
  466. return feed.feed;
  467. else
  468. return [];
  469. }
  470.  
  471. response.forEach((e) => {
  472. feed.push({title: e.title, videoId: e.url.split("?v=").pop(), author: e.uploaderName, authorId: e.uploaderUrl.split("/channel/").pop(), viewCount: e.views, published: (e.uploaded / 1000), lengthSeconds: e.duration});
  473. });
  474. await GM.setValue('feed', {last: Date.now(), feed: feed});
  475. return feed;
  476. }
  477. })();