Invidious Local Subscriptions

Implements local subscriptions on Invidious.

目前為 2023-01-12 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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://greasyfork.org/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 + '&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>'
        }
    }

    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;
    }
})();