Invidious Local Subscriptions

Implements local subscriptions on Invidious.

当前为 2023-01-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. // @exclude *://invidious.dhusch.de/*
  14. // @exclude *://invidious.nerdvpn.de/*
  15. // @exclude *://invidious.weblibre.org/*
  16. // @version 2023.01
  17. // @description Implements local subscriptions on Invidious.
  18. // @run-at document-end
  19. // @grant GM.getValue
  20. // @grant GM.setValue
  21. // @grant GM.xmlHttpRequest
  22. // @license AGPL-3.0-or-later
  23. // @namespace https://greasyfork.org/users/751327
  24. // ==/UserScript==
  25. /**
  26. * This program is free software: you can redistribute it and/or modify
  27. * it under the terms of the GNU Affero General Public License as
  28. * published by the Free Software Foundation, either version 3 of the
  29. * License, or (at your option) any later version.
  30. *
  31. * This program is distributed in the hope that it will be useful,
  32. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  33. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  34. * GNU Affero General Public License for more details.
  35. *
  36. * You should have received a copy of the GNU Affero General Public License
  37. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  38. */
  39. (async function() {
  40. "use strict";
  41. let subscriptions = await GM.getValue("subscriptions") || [];
  42. let settings = await GM.getValue("settings") || {redirect: false};
  43.  
  44. if (location.pathname.toLowerCase().startsWith("/feed/"))
  45. {
  46. if (location.hash.toLowerCase() === "#invlocal") {
  47. document.getElementById("contents").querySelector('div[class="pure-g"]').innerHTML = '\<center id="invlocal-loading" style="letter-spacing: 0 !important;">Fetching subscriptions...</center>';
  48. let feed = await getSubscriptionFeed(false);
  49. displaySubscriptionFeed(feed);
  50. let st = 40;
  51. addEventListener('scroll', function() {
  52. if (window.innerHeight + window.pageYOffset >= document.body.offsetHeight && !document.getElementById("invlocal-loading")) {
  53. displaySubscriptionFeed(feed, st);
  54. st = st + 40;
  55. }});
  56.  
  57. 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>";
  58. document.getElementById("invlocal-refresh").addEventListener('click', async function (e) {
  59. if (!e.target.hasAttribute('disabled'))
  60. {
  61. e.target.setAttribute('disabled', '');
  62. document.getElementById("contents").querySelector('div[class="pure-g"]').innerHTML = '\<center id="invlocal-loading" style="letter-spacing: 0 !important;">Fetching subscriptions...</center>';
  63. feed = await getSubscriptionFeed(true);
  64. displaySubscriptionFeed(feed);
  65. st = 40;
  66. e.target.removeAttribute('disabled');
  67. }
  68. });
  69. }
  70. else {
  71. 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>"
  72. }
  73. }
  74. else if (location.pathname.toLowerCase().startsWith("/channel/") || location.pathname.toLowerCase() === "/watch")
  75. {
  76. const invsubbutton = document.getElementById("subscribe");
  77. const localsubbutton = invsubbutton.cloneNode(true);
  78. localsubbutton.id = "localsubscribe";
  79. localsubbutton.removeAttribute("href");
  80. invsubbutton.parentElement.appendChild(localsubbutton);
  81.  
  82. let chid = "";
  83. let chname = "";
  84. if (location.pathname.toLowerCase().startsWith("/channel/"))
  85. {
  86. chid = location.pathname.split('/')[2];
  87. chname = document.body.querySelector('div[class="channel-profile"] span').textContent.trim();
  88. }
  89. else
  90. {
  91. chid = document.getElementById("published-date").parentElement.querySelector('a[href^="/channel/"]').getAttribute("href").split('/')[2];
  92. chname = document.getElementById("channel-name").textContent.trim();
  93. }
  94.  
  95. if (subscriptions.length > 0 && subscriptions.some(e => e.id === chid))
  96. localsubbutton.innerHTML = "\<b>Unsubscribe Locally</b>";
  97. else
  98. localsubbutton.innerHTML = "\<b>Subscribe Locally</b>";
  99.  
  100. localsubbutton.addEventListener("click", async function(ev) {
  101. if (subscriptions.length > 0 && subscriptions.some(e => e.id === chid)) { //unsubscribe if already subscribed
  102. if (!confirm("Do you really want to unsubscribe from \"" + chname + "\"?"))
  103. return;
  104.  
  105. let x = 0;
  106. subscriptions.forEach(function(e) {
  107. if (e.id === chid)
  108. {
  109. subscriptions.splice(x, 1);
  110. ev.target.innerHTML = "\<b>Subscribe Locally</b>";
  111. }
  112. x++;
  113. });
  114. }
  115. else // subscribe if not
  116. {
  117. subscriptions.push({id: chid, name: chname})
  118. ev.target.innerHTML = "\<b>Unsubscribe Locally</b>";
  119. }
  120. console.log(subscriptions);
  121. await GM.setValue('subscriptions', subscriptions);
  122. });
  123. }
  124. else if (location.pathname.toLowerCase() === "/preferences")
  125. {
  126. let fieldset = document.body.getElementsByTagName("fieldset");
  127. fieldset = fieldset[fieldset.length - 1];
  128. const savebtn = fieldset.getElementsByTagName("button")[0];
  129. const nulegend = document.createElement('legend');
  130. nulegend.textContent = "Local Subscribe Preferences";
  131. const nusettings = document.createElement('div');
  132. nusettings.setAttribute("class", "pure-control-group");
  133. 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>';
  134. fieldset.insertBefore(nulegend, savebtn.parentElement);
  135. fieldset.insertBefore(nusettings, savebtn.parentElement);
  136.  
  137. document.getElementById("invlocal_redirect").checked = settings.redirect;
  138.  
  139. document.getElementById("invlocal_import").addEventListener("click", (ev) => {
  140. const input = document.createElement("input");
  141. input.setAttribute("type", "file");
  142. input.setAttribute("accept", ".json");
  143. input.addEventListener("change", async function(e) {
  144. try {
  145. const file = await input.files[0].text();
  146. const newpipe_subs = JSON.parse(file);
  147. const nusubs = [];
  148. newpipe_subs.subscriptions.forEach((i) => {
  149. if (i.service_id === 0) {
  150. const chanurl = new URL(i.url);
  151. const chanid = chanurl.pathname.split("/channel/").pop().split('/')[0];
  152. nusubs.push({id: chanid, name: i.name});
  153. }
  154. });
  155. subscriptions = nusubs;
  156. await GM.setValue('subscriptions', subscriptions);
  157. await GM.setValue('feed', {last: 0, feed: []});
  158. alert("Subscriptions imported successfully!");
  159. }
  160. catch (ex) { alert("File is corrupted or not supported."); }
  161. });
  162. input.click();
  163. });
  164.  
  165. document.getElementById("invlocal_export").addEventListener("click", (ev) => {
  166. const date = new Date();
  167. 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);
  168. const newpipe_subs = {app_version: "0.24.0", app_version_int: 990, subscriptions: []};
  169. subscriptions.forEach((e) => {
  170. newpipe_subs.subscriptions.push({service_id: 0, url: "https://www.youtube.com/channel/" + e.id, name: e.name});
  171. });
  172. const a = document.createElement("a");
  173. const file = new Blob([JSON.stringify(newpipe_subs)], {type: "application/json"});
  174. a.href = URL.createObjectURL(file);
  175. a.download = "newpipe_subscriptions_" + YYYYMMDDHHmm + ".json";
  176. a.click();
  177. });
  178.  
  179. savebtn.addEventListener("click", (ev) => {
  180. settings.redirect = document.getElementById("invlocal_redirect").checked;
  181. GM.setValue('settings', settings);
  182. });
  183. }
  184.  
  185. if (settings.redirect && location.pathname === "/")
  186. location.replace(location.protocol + "//" + location.hostname + "/feed/#invlocal");
  187. else if (settings.redirect)
  188. document.body.querySelectorAll('a[href="/"]').forEach((el) => el.setAttribute("href","/feed/#invlocal"));
  189.  
  190. function roundViews(views) {
  191. let rounded = 0;
  192. let mode = "";
  193.  
  194. if (views >= 1000000000) {
  195. rounded = views / 1000000000;
  196. mode = "B";
  197. }
  198. else if (views >= 1000000) {
  199. rounded = views / 1000000;
  200. mode = "M"
  201. }
  202. else if (views >= 1000) {
  203. rounded = views / 1000;
  204. mode = "K"
  205. }
  206. else if (views > 1) {
  207. return views + " views";
  208. }
  209. else {
  210. return views + " view";
  211. }
  212.  
  213. if (rounded < 10)
  214. rounded = Math.floor(rounded * 10) / 10;
  215. else
  216. rounded = Math.floor(rounded);
  217.  
  218. return rounded + mode + " views";
  219. }
  220.  
  221. function msToHumanTime(ms) {
  222. const seconds = (ms / 1000);
  223. let tvalue = 0;
  224. let human = "";
  225.  
  226. if (seconds >= 31536000) {
  227. tvalue = Math.floor(seconds / 31536000);
  228. human = "year";
  229. }
  230. else if (seconds >= 2628000) {
  231. tvalue = Math.floor(seconds / 2628000);
  232. human = "month";
  233. }
  234. else if (seconds >= 604800) {
  235. tvalue = Math.floor(seconds / 604800);
  236. human = "week";
  237. }
  238. else if (seconds >= 86400) {
  239. tvalue = Math.floor(seconds / 86400);
  240. human = "day";
  241. }
  242. else if (seconds >= 3600) {
  243. tvalue = Math.floor(seconds / 3600);
  244. human = "hour";
  245. }
  246. else if (seconds >= 60) {
  247. tvalue = Math.floor(seconds / 60);
  248. human = "minute";
  249. }
  250. else {
  251. tvalue = Math.floor(seconds);
  252. human = "second";
  253. }
  254.  
  255. if (tvalue > 1)
  256. return tvalue + ' ' + human + "s ago";
  257. else
  258. return tvalue + ' ' + human + " ago";
  259. }
  260.  
  261. function displaySubscriptionFeed(feed,start = 0) {
  262. const container = document.getElementById("contents").querySelector('div[class="pure-g"]');
  263.  
  264. if (!container || start >= feed.length) {
  265. const elloading = document.getElementById('invlocal-loading');
  266. if (!!elloading && feed.length === 0)
  267. elloading.textContent = "No subscriptions to fetch.";
  268. return;
  269. }
  270.  
  271. if (start === 0)
  272. container.innerHTML = "";
  273.  
  274. let finish = start + 39;
  275. if (finish > (feed.length - 1))
  276. finish = feed.length - 1;
  277. for (let x = start; x <= finish; x++)
  278. {
  279. const date = new Date(0);
  280. date.setSeconds(feed[x].lengthSeconds);
  281. 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 + '&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>'
  282. }
  283. }
  284.  
  285. async function getJson(url) {
  286. const resp = await (function() {
  287. return new Promise((resolve, reject) => {
  288. GM.xmlHttpRequest({
  289. method: 'GET',
  290. url: url,
  291. headers: {
  292. 'Accept': 'application/json'
  293. },
  294. onload: resolve,
  295. onabort: reject,
  296. onerror: reject
  297. });
  298. });
  299. })();
  300. try {
  301. return JSON.parse(resp.responseText);
  302. } catch (e) { return { error: true }; }
  303. }
  304.  
  305. async function getSubscriptionFeed(forced = false) {
  306. let feed = await GM.getValue('feed');
  307. if (forced || !feed || feed.last < (Date.now() - 1800000))
  308. {
  309. let hadError = false;
  310. feed = [];
  311. const instances = [];
  312. let jsonInstances = await getJson("https://api.invidious.io/instances.json");
  313.  
  314. if ((Object.keys(jsonInstances).length === 0 && jsonInstances.construct) || (jsonInstances.hasOwnProperty("error") && jsonInstances.error))
  315. return;
  316.  
  317. jsonInstances.forEach(function(e) {
  318. if (e[1].type === "https" && e[1].api)
  319. {
  320. instances.push(e[0]);
  321. }
  322. });
  323. console.log(instances);
  324.  
  325. if (instances.length <= 0)
  326. return;
  327.  
  328. for (let x = 0; x < subscriptions.length; x++)
  329. {
  330. document.getElementById('invlocal-loading').textContent = "Fetching channel " + (x + 1) + " out of " + subscriptions.length;
  331. 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");
  332.  
  333. if (response.hasOwnProperty("error"))
  334. {
  335. hadError = true;
  336. continue;
  337. }
  338. else if (response.hasOwnProperty("videos"))
  339. response = response.videos;
  340.  
  341. feed = feed.concat(response);
  342. }
  343.  
  344. feed.sort((a, b) => b.published - a.published);
  345.  
  346. if (!hadError)
  347. await GM.setValue('feed', {last: Date.now(), feed: feed});
  348. else
  349. await GM.setValue('feed', {last: 0, feed: feed});
  350.  
  351. return feed;
  352. }
  353. else
  354. return feed.feed;
  355. }
  356. })();