// ==UserScript== // @name Mark Watched YouTube Videos // @namespace MarkWatchedYouTubeVideos // @version 1.1.28 // @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 https://greasyfork.org/en/users/85671-jcunews // @match *://www.youtube.com/* // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_setValue // @run-at document-start // @downloadURL none // ==/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. - For restoring history, source file can also be a list of YouTube video URLs, but timestamps will be set to current time. */ (() => { //=== 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, xu = /\/watch(?:\?|.*?&)v=([^&]+)/; function getVideoId(url) { var vid = url.match(xu); 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"); //history:watch page processVideoItems('ytd-section-list-renderer[page-subtype="history"] .ytd-item-section-renderer>ytd-video-renderer'); //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:, timestamp:}, ...] //new: {entries:{:, ...}, index:[, ...]} 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 arr ? Math.round(arr.reduce(sum, 0) / arr.length) : "(n/a)"; } 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]++; }); if (watchedVideos.index.length) { pd = (new Date(watchedVideos.entries[watchedVideos.index[0]])).toLocaleString(); pm = (new Date(watchedVideos.entries[watchedVideos.index[watchedVideos.index.length - 1]])).toLocaleString(); } else { pd = "(n/a)"; pm = "(n/a)"; } alert(`\ Number of entries: ${watchedVideos.index.length} Oldest entry: ${pd} Newest entry: ${pm} 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) => { function askRestore(r, o) { if (confirm(`Selected history data file contains ${o.index.length} entries.\n\nRestore from this data?`)) { watchedVideos = o; GM_setValue("watchedVideos", r); a.remove(); doProcessPage(); } } (a = document.createElement("DIV")).id = "mwyvrh_ujs"; a.innerHTML = `
Mark Watched YouTube Videos
Please select a file to restore history data from.
`; a.onclick = e => { (e.target === a) && a.remove(); }; (b = a.querySelector("#mwyvrhi_ujs")).onchange = r => { r = new FileReader(); r.onload = (o, t) => { if (o = parseData(r = r.result)) { //parse as native format if (o.index.length) { askRestore(r, o); } else alert("File doesn't have any history entry."); } else { //parse as URL list o = {entries: {}, index: []}; t = (new Date()).getTime(); r = r.replace(/\r/g, "").split("\n"); while (r.length && !r[0].trim()) r.shift(); if (r.length && xu.test(r[0])) { r.forEach(s => { if (s = s.match(xu)) { o.entries[s[1]] = t; o.index.push(s[1]); } }); if (o.index.length) { askRestore(JSON.stringify(o), o); } else alert("File doesn't have any history entry."); } else alert("Invalid history data file."); } }; r.readAsText(b.files[0]); }; document.documentElement.appendChild(a); b.click(); }); })();