Greasy Fork

Greasy Fork is available in English.

Mark Watched YouTube Videos

Add an indicator for watched videos on YouTube. Use GM menus to display history statistics, backup history, and restore history.

目前为 2019-11-17 提交的版本,查看 最新版本

// ==UserScript==
// @name        Mark Watched YouTube Videos
// @namespace   MarkWatchedYouTubeVideos
// @version     1.1.24
// @license     AGPL v3
// @author      jcunews
// @description Add an indicator for watched videos on YouTube. Use GM menus to display history statistics, backup history, and restore history.
// @website     http://greasyfork.icu/en/users/85671-jcunews
// @match       *://www.youtube.com/*
// @grant       GM_getValue
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @run-at      document-start
// ==/UserScript==

/*
Use ALT+LeftClick or ALT+RightClick on a video list item to manually toggle the watched marker. The mouse button is defined in the script and can be changed.
*/

(() => {
  
  //=== config start ===
  var maxWatchedVideoAge   = 5 * 365; //number of days. set to zero to disable (not recommended)
  var pageLoadMarkDelay    = 400;     //number of milliseconds to wait before marking video items on page load phase (increase if slow network/browser)
  var contentLoadMarkDelay = 600;     //number of milliseconds to wait before marking video items on content load phase (increase if slow network/browser)
  var markerMouseButtons   = [0, 1];  //one or more mouse buttons to use for manual marker toggle. 0=left, 1=right, 2=middle. e.g.:
                                      //if `[0]`, only left button is used, which is ALT+LeftClick.
                                      //if `[1]`, only right button is used, which is ALT+RightClick.
                                      //if `[0,1]`, any left or right button can be used, which is: ALT+LeftClick or ALT+RightClick.
  //=== config end ===

  var watchedVideos, ageMultiplier = 24 * 60 * 60 * 1000;

  function getVideoId(url) {
    var vid = url.match(/\/watch(?:\?|.*?&)v=([^&]+)/);
    if (vid) vid = vid[1] || vid[2];
    return vid;
  }

  function watched(vid) {
    return !!watchedVideos.entries[vid];
  }

  function processVideoItems(selector) {
    var items = document.querySelectorAll(selector), i, link;
    for (i = items.length-1; i >= 0; i--) {
      if (link = items[i].querySelector("A")) {
        if (watched(getVideoId(link.href))) {
          items[i].classList.add("watched");
        } else items[i].classList.remove("watched");
      }
    }
  }

  function processAllVideoItems() {
    //home page
    processVideoItems(".yt-uix-shelfslider-list>.yt-shelf-grid-item");
    processVideoItems("#contents.ytd-rich-grid-renderer>ytd-rich-item-renderer,#contents.ytd-rich-shelf-renderer ytd-rich-item-renderer.ytd-rich-shelf-renderer");
    //subscriptions page
    processVideoItems(".multirow-shelf>.shelf-content>.yt-shelf-grid-item");
    //channel/user home page
    processVideoItems("#contents>.ytd-item-section-renderer>.ytd-newspaper-renderer,#items>.yt-horizontal-list-renderer"); //old
    processVideoItems("#contents>.ytd-channel-featured-content-renderer,#contents>.ytd-shelf-renderer>#grid-container>.ytd-expanded-shelf-contents-renderer"); //new
    //channel/user video page
    processVideoItems(".yt-uix-slider-list>.featured-content-item,#items>.ytd-grid-renderer");
    //channel/user playlist page
    processVideoItems(".expanded-shelf>.expanded-shelf-content-list>.expanded-shelf-content-item-wrapper,.ytd-playlist-video-renderer");
    //channel/user playlist item page
    processVideoItems(".pl-video-list .pl-video-table .pl-video,ytd-playlist-panel-video-renderer");
    //channel/user videos page
    processVideoItems(".channels-browse-content-grid>.channels-content-item");
    //channel/user search page
    if (/^\/(?:channel|user)\/.*?\/search/.test(location.pathname)) {
      processVideoItems(".ytd-browse #contents>.ytd-item-section-renderer"); //new
    }
    //search page
    processVideoItems("#results>.section-list .item-section>li,#browse-items-primary>.browse-list-item-container"); //old
    processVideoItems(".ytd-search #contents>.ytd-item-section-renderer"); //new
    //video page sidebar
    processVideoItems(".watch-sidebar-body>.video-list>.video-list-item,.playlist-videos-container>.playlist-videos-list>li"); //old
    processVideoItems(".ytd-compact-video-renderer"); //new
  }

  function addHistory(vid, time, noSave) {
    watchedVideos.entries[vid] = time;
    watchedVideos.index.push(vid);
    if (!noSave) GM_setValue("watchedVideos", JSON.stringify(watchedVideos));
  }

  function delHistory(index, noSave) {
    delete watchedVideos.entries[watchedVideos.index[index]];
    watchedVideos.index.splice(index, 1);
    if (!noSave) GM_setValue("watchedVideos", JSON.stringify(watchedVideos));
  }

  function parseData(s, a) {
    try {
      s = JSON.parse(s);
      //convert to new format if old format.
      //old: [{id:<strVID>, timestamp:<numDate>}, ...]
      //new: {entries:{<stdVID>:<numDate>, ...}, index:[<strVID>, ...]}
      if (Array.isArray(s)) {
        a = s;
        s = {entries: {}, index: []};
        a.forEach(o => {
          s.entries[o.id] = o.timestamp;
          s.index.push(o.id);
        });
      } else if (("object" !== typeof s) || ("object" !== typeof s.entries) || !Array.isArray(s.index)) return null;
      return s;
    } catch(z) {
      return null;
    }
  }

  function getHistory(a, b) {
    a = GM_getValue("watchedVideos") || '{"entries": {}, "index": []}';
    if (b = parseData(a)) {
      watchedVideos = b;
    } else a = JSON.stringify(watchedVideos = {entries: {}, index: []});
    GM_setValue("watchedVideos", a);
  }

  function doProcessPage() {
    //get list of watched videos
    getHistory();

    //remove old watched video history
    var now = (new Date()).valueOf(), changed, vid;
    if (maxWatchedVideoAge > 0) {
      while (watchedVideos.index.length) {
        if (((now - watchedVideos.entries[watchedVideos.index[0]]) / ageMultiplier) > maxWatchedVideoAge) {
          delHistory(0, false);
          changed = true;
        } else break;
      }
      if (changed) GM_setValue("watchedVideos", JSON.stringify(watchedVideos));
    }

    //check and remember current video
    if ((vid = getVideoId(location.href)) && !watched(vid)) addHistory(vid, now);

    //mark watched videos
    processAllVideoItems();
  }

  function processPage() {
    setTimeout(doProcessPage, 200);
  }

  function toggleMarker(ele, i) {
    if (ele) {
      if (ele.href) {
        i = getVideoId(ele.href);
      } else {
        ele = ele.parentNode;
        while (ele) {
          if (ele.tagName === "A") {
            i = getVideoId(ele.href);
            break;
          }
          ele = ele.parentNode;
        }
      }
      if (i) {
        if ((ele = watchedVideos.index.indexOf(i)) >= 0) {
          delHistory(ele);
        } else addHistory(i, (new Date()).valueOf());
        processAllVideoItems();
      }
    }
  }

  var xhropen = XMLHttpRequest.prototype.open, xhrsend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function(method, url) {
    this.url_mwyv = url;
    return xhropen.apply(this, arguments);
  };
  XMLHttpRequest.prototype.send = function(method, url) {
    if ((/\/\w+_ajax\?|\/results\?search_query/).test(this.url_mwyv) && !this.listened_mwyv) {
      this.listened_mwyv = 1;
      this.addEventListener("load", () => {
        setTimeout(processPage, Math.floor(pageLoadMarkDelay / 2));
      });
    }
    return xhrsend.apply(this, arguments);
  };

  addEventListener("DOMContentLoaded", sty => {
    sty = document.createElement("STYLE");
    sty.innerHTML = `
.watched, .watched .yt-ui-ellipsis
  { background-color: #cec !important }
html[dark] .watched, html[dark] .watched .yt-ui-ellipsis,
.playlist-videos-container>.playlist-videos-list>li.watched,
.playlist-videos-container>.playlist-videos-list>li.watched>a,
.playlist-videos-container>.playlist-videos-list>li.watched .yt-ui-ellipsis
  { background-color: #030 !important }`;
    document.head.appendChild(sty);
  });

  var lastFocusState = document.hasFocus();
  addEventListener("blur", () => {
    lastFocusState = false;
  });
  addEventListener("focus", () => {
    if (!lastFocusState) processPage();
    lastFocusState = true;
  });
  addEventListener("click", (ev) => {
    if ((markerMouseButtons.indexOf(ev.button) >= 0) && ev.altKey) toggleMarker(ev.target);
  });
  if (markerMouseButtons.indexOf(1) >= 0) {
    addEventListener("contextmenu", (ev) => {
      if (ev.altKey) toggleMarker(ev.target);
    });
  }
  if (window["body-container"]) { //old
    addEventListener("spfdone", processPage);
    processPage();
  } else { //new
    var t = 0;
    function pl() {
      clearTimeout(t);
      t = setTimeout(processPage, 300);
    }
    (function init(vm) {
      if (vm = document.getElementById("visibility-monitor")) {
        vm.addEventListener("viewport-load", pl);
      } else setTimeout(init, 100);
    })();
    (function init2(mh) {
      if (mh = document.getElementById("masthead")) {
        mh.addEventListener("yt-rendererstamper-finished", pl);
      } else setTimeout(init2, 100);
    })();
    addEventListener("load", () => {
      setTimeout(processPage, pageLoadMarkDelay);
    });
    addEventListener("spfprocess", () => {
      setTimeout(processPage, contentLoadMarkDelay);
    });
  }
  GM_registerMenuCommand("Display History Statistics", () => {
    function sum(r, v) {
      return r + v;
    }
    function avg(arr) {
      return Math.round(arr.reduce(sum, 0) / arr.length);
    }
    var pd, pm, py, ld = [], lm = [], ly = [];
    getHistory();
    Object.keys(watchedVideos.entries).forEach((k, t) => {
      t = new Date(watchedVideos.entries[k]);
      if (!pd || (pd !== t.getDate())) {
        ld.push(1);
        pd = t.getDate();
      } else ld[ld.length - 1]++;
      if (!pm || (pm !== (t.getMonth() + 1))) {
        lm.push(1);
        pm = t.getMonth() + 1;
      } else lm[lm.length - 1]++;
      if (!py || (py !== t.getFullYear())) {
        ly.push(1);
        py = t.getFullYear();
      } else ly[ly.length - 1]++;
    });
    alert(`\
Number of entries: ${watchedVideos.index.length}
Oldest entry: ${(new Date(watchedVideos.entries[watchedVideos.index[0]])).toLocaleString()}
Newest entry: ${(new Date(watchedVideos.entries[watchedVideos.index[watchedVideos.index.length - 1]])).toLocaleString()}

Average viewed videos per day: ${avg(ld)}
Average viewed videos per month: ${avg(lm)}
Average viewed videos per year: ${avg(ly)}\
`);
  });
  GM_registerMenuCommand("Backup History Data", (a, b) => {
    document.body.appendChild(a = document.createElement("A")).href = URL.createObjectURL(new Blob([JSON.stringify(watchedVideos)], {type: "application/json"}));
    a.download = `MarkWatchedYouTubeVideos_${(new Date()).toISOString()}.json`;
    a.click();
    a.remove();
    URL.revokeObjectURL(a.href);
  });
  GM_registerMenuCommand("Restore History Data", (a, b) => {
    (a = document.createElement("DIV")).id = "mwyvrh_ujs";
    a.innerHTML = `<style>
#mwyvrh_ujs{
  display:flex;position:fixed;z-index:99999;left:0;top:0;right:0;bottom:0;margin:0;border:none;padding:0;background:rgb(0,0,0,0.5);
  color:#000;font-family:sans-serif;font-size:12pt;line-height:12pt;font-weight:normal;cursor:pointer;
}
#mwyvrhb_ujs{
  margin:auto;border:.3rem solid #007;border-radius:.3rem;padding:.5rem;background-color:#fff;cursor:auto;
}
#mwyvrht_ujs{margin-bottom:1rem;font-size:14pt;line-height:14pt;font-weight:bold}
#mwyvrhi_ujs{display:block;margin:1rem auto .5rem auto;width:12rem;overflow:hidden}
</style>
<div id="mwyvrhb_ujs">
  <div id="mwyvrht_ujs">Mark Watched YouTube Videos</div>
  Please select a file to restore history data from.
  <input id="mwyvrhi_ujs" type="file" multiple />
</div>`;
    a.onclick = e => {
      (e.target === a) && a.remove();
    };
    (b = a.querySelector("#mwyvrhi_ujs")).oninput = r => {
      r = new FileReader();
      r.onload = o => {
        if (o = parseData(r = r.result)) {
          if (confirm(`Selected history data file contains ${o.index.length} entries.\n\nRestore from this data?`)) {
            watchedVideos = o;
            GM_setValue("watchedVideos", r);
            a.remove();
          }
        } else alert("Invalid history data file.");
      };
      r.readAsText(b.files[0]);
    };
    document.documentElement.appendChild(a);
    b.click();
  });
})();