wolfish / YouTube Unsub Checker

// ==UserScript==
// @name         YouTube Unsub Checker
// @namespace    http://tampermonkey.net/
// @require      http://code.jquery.com/jquery-3.4.1.slim.min.js
// @version      1.41
// @description  Tampermonkey script that lets you check which YouTube channels you've unsubscribed from. Can be used to detect if YouTube has automatically unsubcribed you from any channels. The script only runs on this page: https://www.youtube.com/feed/channels
// @author       Wolfish
// @copyright    2019, Wolfish (https://wolfish.neocities.org/)
// @homepageURL  https://wolfish.neocities.org/posts/articles/youtube_unsub_checker/
// @license      MIT
// @match        https://*.youtube.com/feed/channels
// @grant        none
// @run-at       document-idle
// ==/UserScript==

function SubItem(name, url) {
    this.name = name;
    this.url = url;
}

var VERSION = 1.41;

var scrollTickInterval = 500; // in milliseconds
var scrollHaltThreshold = 20; // number of tick intervals that no scroll dist change is detected before halting scan

var userID;
var subsMap = {};
var bHasScannedSubs = false;
var bScanningSubs = false;
var scrollIntervalID;
var scrollHaltCounter = 0;
var prevScrollDist = 0;

(function() {
    'use strict';

    if (typeof(Storage) == "undefined") {
        console.log("YouTube Unsub Checker depends upon HTML5 Web Storage, which your browser does not support!");
        return;
    }

    var containerStyle =
        'style="\
        width: 400px;\
        height: auto;\
        position: fixed;\
        top: 45px;\
        right: calc(50vw - 200px);\
        font-family: monospace;\
        color: white;\
        z-index: 99999;"';

    var mainDialogStyle =
        'style="\
        background: black;\
        border: 1px solid #23ff00;\
        width: 100%;\
        height: 67px;\
        padding: 2px 10px;"';

    var infoDialogStyle =
        'style="\
        width:400px;\
        max-height: 300px;\
        border: 1px solid #23ff00;\
        border-top: none;\
        background: black;\
        padding: 5px 10px;\
        overflow-y: auto;\"';

    var titleStyle =
        'style="\
        font-size: 1.2em;\
        margin-bottom: 5px;"';

    $(document.body).append(
        '<div id="yt_unsub_checker_container" ' + containerStyle + ' >\
            <div id="yt_unsub_checker_floating_dialog" ' + mainDialogStyle + ' >\
                <div ' + titleStyle + '>\
                    <a href="https://wolfish.neocities.org/posts/articles/youtube_unsub_checker/"\
                    style="color: #00f1ff;" target="_blank">YouTube Unsub Checker v.' + VERSION + '</a>\
                </div>\
                <div id="yt_unsub_checker_floating_dialog_cached_subs_msg"></div>\
                <div id="yt_unsub_checker_floating_dialog_current_subs_msg"></div>\
                <div id="yt_unsub_checker_floating_dialog_buttons" style="display: table; text-align: center;margin: 5px auto;"></div>\
            </div>\
            <div id="yt_unsub_checker_floating_info" ' + infoDialogStyle + ' >\
                <div id="yt_unsub_checker_floating_dialog_message"></div>\
                <div id="yt_unsub_checker_confirmation_button" style="display: table; text-align: center;margin: 5px auto;"></div>\
                <div id="yt_unsub_checker_extra_tools_buttons" style="display: table; text-align: center;margin: 5px auto;"></div>\
                <div id="yt_unsub_checker_floating_info_print"></div>\
            </div>\
        </div>');

    // Cache UserID from page source (must be done before calling update functions)
    userID = getUserID();

    updateCachedSubsMsg();
    updateScannedSubsMsg();
    updateDialogMsg("Ready! Begin by clicking 'Scan Subs'.");

    var buttonStyle = "font-family: monospace;font-size: 0.9em;margin-right: 5px;";
    var buttonStyleLast = "font-family: monospace;font-size: 0.9em;";
    var buttonAppend = "#yt_unsub_checker_floating_dialog #yt_unsub_checker_floating_dialog_buttons";

    // Main buttons
    var buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Scan Subs";
    buttonNode.addEventListener ("click", scanSubsButton);
    $(buttonNode).appendTo(buttonAppend);

    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Check For Unsubs";
    buttonNode.addEventListener ("click", checkForUnsubsButton);
    $(buttonNode).appendTo(buttonAppend);

    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Update Cache";
    buttonNode.addEventListener ("click", updateCacheButton);
    $(buttonNode).appendTo(buttonAppend);

    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Extra Tools";
    buttonNode.addEventListener ("click", extraToolsButton);
    $(buttonNode).appendTo(buttonAppend);

    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyleLast;
    buttonNode.innerHTML = "Hide";
    buttonNode.addEventListener ("click", hideYTUnsubChecker);
    $(buttonNode).appendTo(buttonAppend);

    buttonAppend = "#yt_unsub_checker_floating_info #yt_unsub_checker_confirmation_button";

    // Confirmation button
    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyleLast;
    buttonNode.innerHTML = "Confirm";
    $(buttonNode).appendTo(buttonAppend);

    buttonAppend = "#yt_unsub_checker_floating_info #yt_unsub_checker_extra_tools_buttons";

    // Extra tools buttons
    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Clear Cache";
    buttonNode.addEventListener ("click", clearCacheButton);
    $(buttonNode).appendTo(buttonAppend);

    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Download Cache \(.txt\)";
    buttonNode.addEventListener ("click", downloadTXTButton);
    $(buttonNode).appendTo(buttonAppend);

    buttonNode = document.createElement('button');
    buttonNode.style = buttonStyle;
    buttonNode.innerHTML = "Download Cache \(.html\)";
    buttonNode.addEventListener ("click", downloadHTMLButton);
    $(buttonNode).appendTo(buttonAppend);

    // Hide buttons and textboxes etc.
    hideInfoNodes();
})();

function scanSubsButton() {
    hideInfoNodes();
    collectCurrentSubs();
}

function checkForUnsubsButton() {
    if (bScanningSubs == true) return;

    hideInfoNodes();

    if (bHasScannedSubs == false) {
        updateDialogMsg("First you need to scan your subs! Click 'Scan Subs'.");
        return;
    }

    checkForUnsubs();
}

function updateCacheButton() {
    if (bScanningSubs == true) return;

    hideInfoNodes();

    if (bHasScannedSubs == false) {
        updateDialogMsg("First you need to scan your subs! Click 'Scan Subs'.");
        return;
    }

    updateDialogMsg("Replace current cache with most recent scan?");
    setConfirmationFunc(
        function() {
            saveYTSubs();
            hideConfirmationButton();
        });
    showConfirmationButton();
}

function extraToolsButton() {
    if (bScanningSubs == true) return;

    hideInfoNodes();
    showExtraTools();
    updateDialogMsg("Displaying extra tools.");
    updateInfoDialog("");
}

function clearCacheButton() {
    if (bScanningSubs == true) return;

    hideInfoNodes();
    updateDialogMsg("Are you sure you want to clear your subscription cache?");
    setConfirmationFunc(
        function() {
            clearCachedSubs();
            hideConfirmationButton();
        });
    showConfirmationButton();
}

function downloadTXTButton() {
    if (bScanningSubs == true) return;

    downloadData(subMapToText(getCachedSubsMap()), "ytsubs.txt", 'txt');
}

function downloadHTMLButton() {
    if (bScanningSubs == true) return;

    downloadData(subMapToHTML(getCachedSubsMap()), "ytsubs.html", 'html');
}

function getUserID() {
    var pageSource = document.documentElement.innerHTML;
    var subStr = /creator_channel_id.*?[}]/.exec(pageSource)[0];
    subStr = subStr.split(':')[1].split('"')[1];
    return subStr;
}

function scanSubs() {
    subsMap = {};
    updateScannedSubsMsg();

    // Each subbed channel info element
    $(".style-scope.ytd-expanded-shelf-contents-renderer .yt-simple-endpoint.style-scope.ytd-channel-renderer").each(function() {
        var url = this.href;
        var name = "";

        $(this).find("#channel-title").find("yt-formatted-string").each(function() {
            if ($(this).is(".style-scope.ytd-channel-name")) {
                name = this.innerHTML;
            }
        });

        if (name != "") {
            subsMap[name] = new SubItem(name, url);
        }

        updateScannedSubsMsg();
    });

    bHasScannedSubs = true;
}

function getSubsCount(inMap) {
    var count = 0;
    for (var subPair in inMap) {
        if (inMap.hasOwnProperty(subPair)) {
            count++;
        }
    }
    return count;
}

function clearCachedSubs() {
    localStorage.ytUnsubChecker = "";
    updateCachedSubsMsg();
    updateDialogMsg("Cache cleared.");
}

function hideYTUnsubChecker() {
    clearInterval(scrollIntervalID);
    $("#yt_unsub_checker_container").each(function() {
        this.style = "display: block;display: none;";
    });
}

function updateCachedSubsMsg() {
    $("#yt_unsub_checker_floating_dialog_cached_subs_msg").each(function() {
        this.innerHTML = "Number of subscriptions saved in cache: <strong>&nbsp&nbsp&nbsp" + getSubsCount(getCachedSubsMap()) + "</strong>";
    });
}

function updateScannedSubsMsg() {
    $("#yt_unsub_checker_floating_dialog_current_subs_msg").each(function() {
        this.innerHTML = "Number of subscriptions scanned from page: <strong>" + getSubsCount(subsMap) + "</strong>";
    });
}

function updateDialogMsg(newHTML) {
    $("#yt_unsub_checker_floating_dialog_message").each(function() {
        this.innerHTML = "<strong>Status: </strong>" + newHTML;
    });
}

function updateInfoDialog(newHTML) {
    $("#yt_unsub_checker_floating_info_print").each(function() {
        this.innerHTML = newHTML;
    });
    showInfoPrint();
}

function collectCurrentSubs() {
    if (bScanningSubs == true) return;

    bScanningSubs = true;
    updateDialogMsg("Scanning subscriptions... please wait.");

    scrollHaltCounter = 0;
    prevScrollDist = 0;

    scrollIntervalID = setInterval(function() {
        document.documentElement.scrollTop = document.documentElement.scrollHeight;
        scanSubs();
        // Stop scrolling interval after no change in page scroll detected
        if (document.documentElement.scrollTop == prevScrollDist) {
            scrollHaltCounter++;
            if (scrollHaltCounter >= scrollHaltThreshold) {
                clearInterval(scrollIntervalID);
                bScanningSubs = false;
                updateDialogMsg("Subscription scan complete! Now click 'Check For Unsubs'.");
            }
        }
        prevScrollDist = document.documentElement.scrollTop;
    }, scrollTickInterval);
}

function getCachedUserMap() {
    try {
        return JSON.parse(localStorage.ytUnsubChecker);
    } catch(e) {
        return {};
    }
}

function saveSubsMap() {
    var cachedUserMap = getCachedUserMap();
    cachedUserMap[userID] = subsMap;
    localStorage.ytUnsubChecker = JSON.stringify(cachedUserMap);
}

function getCachedSubsMap() {
    return getCachedUserMap()[userID];
}

function saveYTSubs() {
    saveSubsMap();
    updateCachedSubsMsg();
    updateDialogMsg("Cache updated.");
}

function checkForUnsubs() {
    var unsubs = "";
    var cachedSubs = getCachedSubsMap();
    var cachedSubsCount = getSubsCount(cachedSubs);
    var subsCount = getSubsCount(subsMap);
    var msg = "";

    for (var cachedSubPair in cachedSubs) {
        if (cachedSubs.hasOwnProperty(cachedSubPair)) {
            var cachedSub = cachedSubs[cachedSubPair];
            if (cachedSub != undefined) {
                var sub = subsMap[cachedSub.name];
                if (sub == undefined) {
                    unsubs += '<a style="color: #00f1ff;" href=\"' + cachedSub.url + '\">' + cachedSub.name + '</a><br /><br />';
                }
            }
        }
    }

    if (unsubs != "") {
        msg = "<br/><strong>Unsubscribed channels:</strong><br /><br />" + unsubs;
        updateDialogMsg("Unsubs detected!");
        updateInfoDialog(msg);
    } else {
        msg = "No unsubs detected!";
        if (subsCount > cachedSubsCount) {
            msg += " New subs detected, consider clicking 'Update Cache'.";
        } else if (subsCount == cachedSubsCount) {
            msg += " Subscription cache is up to date.";
        }
        updateDialogMsg(msg);
        updateInfoDialog("");
    }
}

function hideInfoNodes() {
    hideConfirmationButton();
    hideExtraTools();
    hideInfoPrint();
}

function showConfirmationButton() {
    $("#yt_unsub_checker_confirmation_button").show();
}

function hideConfirmationButton() {
    $("#yt_unsub_checker_confirmation_button").hide();
}

function setConfirmationFunc(newFunc) {
    $("#yt_unsub_checker_confirmation_button").off();
    $("#yt_unsub_checker_confirmation_button").on("click", newFunc);
}

function showExtraTools() {
    $("#yt_unsub_checker_extra_tools_buttons").show();
}

function hideExtraTools() {
    $("#yt_unsub_checker_extra_tools_buttons").hide();
}

function showInfoPrint() {
    $("#yt_unsub_checker_floating_info_print").show();
}

function hideInfoPrint() {
    $("#yt_unsub_checker_floating_info_print").hide();
}

function downloadData(data, filename, type) {
    var file = new Blob([data], {type: type});
    if (window.navigator.msSaveOrOpenBlob) { // IE10+
        window.navigator.msSaveOrOpenBlob(file, filename);
    } else { // Others
        var a = document.createElement("a"),
                url = URL.createObjectURL(file);
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        setTimeout(function() {
            document.body.removeChild(a);
            window.URL.revokeObjectURL(url);
        }, 0);
    }
}

function subMapToText(inMap) {
    var subStr = "";

    for (var subPair in inMap) {
        if (inMap.hasOwnProperty(subPair)) {
            var sub = inMap[subPair];
            if (sub != undefined) {
                subStr += sub.name + "\r\n" + sub.url + "\r\n\r\n";
            }
        }
    }

    return subStr;
}

function subMapToHTML(inMap) {
    var htm = "<HTML><head><title>YouTube UnSub Checker: Subscriptions Backup</title></head><body>";

    for (var subPair in inMap) {
        if (inMap.hasOwnProperty(subPair)) {
            var sub = inMap[subPair];
            if (sub != undefined) {
                htm += '<a href="' + sub.url + '">' + sub.name + '</a>' + '<br /><br />';
            }
        }
    }

    htm += "</body></HTML>";
    return htm;
}