yorotiba / YouTube Popcorn

// ==UserScript==
// @name           YouTube Popcorn
// @namespace      http://youtube.com/channel/UC_bMljS4sETid3es1d_nqzw
// @homepageURL    http://youtube.com/channel/UC_bMljS4sETid3es1d_nqzw
// @description    Show Quality on Pop in corner Image, and possibility direct download.
// @author         Yorotiba
// @oujs:author    Yorotiba
// @version        1.2
// @supporturl     mailto:yorotiba@gmx.fr
// @copyright      2014 , Yorotiba
// @include        http://*.youtube.com/*
// @include        http://youtube.com/*
// @include        https://*.youtube.com/*
// @include        https://youtube.com/*
// @match          *://*.youtube.com/*
// @match          *://*.googlevideo.com/*
// @match          *://s.ytimg.com/yts/jsbin/*
// @grant          GM_xmlhttpRequest
// ==/UserScript==

/* This is based & remake on YouTube Links 1.62 */

/* Tested on Firefox 2+ Chrome 2+ and Opera 1+ */

	(function() {

	// =============================================================================

	var win = typeof(unsafeWindow) !== "undefined" ? unsafeWindow : window;
	var doc = win.document;
	var loc = win.location;

	if(win.top != win.self)	
	  return;

	var unsafeWin = win;

	// Hack to get unsafe window in Chrome
	(function() {

	var isChrome = navigator.userAgent.toLowerCase().indexOf("chrome") >= 0;

	if(!isChrome)
	  return;

	// Chrome 27 fixed this exploit, but luckily, its unsafeWin now works for us
	try {
	  var div = doc.createElement("div");
	  div.setAttribute("onclick", "return window;");
	  unsafeWin = div.onclick();
	} catch(e) {
	  }

	}) ();

	// =============================================================================

    var SCRIPT_NAME = "YouTube popcorn";
    
    var relInfo = {
        ver: 10100,
        ts: 2015030700,
        desc: "Fix bug, add 4 -sig, more set quality "
    };
    
    var SCRIPT_UPDATE_LINK = loc.protocol + "//greasyfork.org/scripts/5552-youtube-popcorn/code/YouTube Popcorn.user.js";
    var SCRIPT_LINK = loc.protocol + "//greasyfork.org/scripts/5552-youtube-popcorn/code/YouTube Popcorn.user.js";

	// =============================================================================

	var dom = {};

	dom.gE = function(id) {
	  return doc.getElementById(id);
	  };

	dom.gT = function(dom, tag) {
	  if(arguments.length == 1) {
		tag = dom;
		dom = doc;
		}

	  return dom.getElementsByTagName(tag);
	  };

	dom.cE = function(tag) {
	  return document.createElement(tag);
	  };

	dom.cT = function(s) {
	  return doc.createTextNode(s);
	  };

	dom.attr = function(obj, k, v) {
	  if(arguments.length == 2)
		return obj.getAttribute(k);

	  obj.setAttribute(k, v);
	  };

	dom.prepend = function(obj, child) {
	  obj.insertBefore(child, obj.firstChild);
	  };

	dom.append = function(obj, child) {
	  obj.appendChild(child);
	  };

	dom.offset = function(obj) {
	  var x = 0;
	  var y = 0;

	  if(obj.getBoundingClientRect) {
		var box = obj.getBoundingClientRect();
		var owner = obj.ownerDocument;

		x = box.left + Math.max(owner.documentElement.scrollLeft, owner.body.scrollLeft) - owner.documentElement.clientLeft;
		y = box.top + Math.max(owner.documentElement.scrollTop, owner.body.scrollTop) - owner.documentElement.clientTop;

		return { left: x, top: y };
		}

	  if(obj.offsetParent) {
		do {
		  x += obj.offsetLeft - obj.scrollLeft;
		  y += obj.offsetTop - obj.scrollTop;
		  obj = obj.offsetParent;
		} while(obj);
		}

	  return { left: x, top: y };
	  };

	dom.inViewport = function(el) {
	  var rect = el.getBoundingClientRect();

	  return rect.bottom >= 0 &&
	   rect.right >= 0 &&
	   rect.top < (win.innerHeight || doc.documentElement.clientHeight) &&
	   rect.left < (win.innerWidth || doc.documentElement.clientWidth);
	  };

	dom.html = function(obj, s) {
	  if(arguments.length == 1)
		return obj.innerHTML;

	  obj.innerHTML = s;
	  };

	dom.emitHtml = function(tag, attrs, body) {
	  if(arguments.length == 2) {
		if(typeof(attrs) == "string") {
		  body = attrs;
		  attrs = {};
		  }
		}

	  var list = [];

	  for(var k in attrs) {
		list.push(k + "='" + attrs[k].replace(/'/g, "\\'") + "'");
		}

	  var s = "<" + tag + " " + list.join(" ") + ">";

	  if(body != null)
		s += body + "</" + tag + ">";

	  return s;
	  };

	dom.emitCssStyles = function(styles) {
	  var list = [];

	  for(var k in styles) {
		list.push(k + ": " + styles[k] + ";");
		}

	  return " { " + list.join(" ") + " }";
	  };

	dom.ajax = function(opts) {
	  function newXhr() {
		if(window.ActiveXObject) {
		  try {
			return new ActiveXObject("Msxml2.XMLHTTP");
			} catch(e) {
			  }

		  try {
			return new ActiveXObject("Microsoft.XMLHTTP");
			} catch(e) {
			  return null;
			  }
		  }

		if(window.XMLHttpRequest)
		  return new XMLHttpRequest();

		return null;
		}

	  function nop() {
		}

	  // Entry point
	  var xhr = newXhr();

	  opts = addProp({
		type: "GET",
		async: true,
		success: nop,
		error: nop,
		complete: nop
		}, opts);

	  xhr.open(opts.type, opts.url, opts.async);

	  xhr.onreadystatechange = function() {
		if(xhr.readyState == 4) {
		  var status = +xhr.status;

		  if(status >= 100 && status < 300) {
			opts.success(xhr.responseText, "success", xhr);
			}
		  else {
			opts.error(xhr, "error");
			}

		  opts.complete(xhr);
		  }
		};

	  xhr.send("");
	  };

	dom.crossAjax = function(opts) {
	  function wrapXhr(xhr) {
		var headers = xhr.responseHeaders.replace("\r", "").split("\n");

		var obj = {};

		forEach(headers, function(idx, elm) {
		  var nv = elm.split(":");
		  if(nv[1] != null)
			obj[nv[0].toLowerCase()] = nv[1].replace(/^\s+/, "").replace(/\s+$/, "");
		  });

		var responseXML = null;

		if(opts.dataType == "xml")
		  responseXML = new DOMParser().parseFromString(xhr.responseText, "text/xml");

		return {
		  responseText: xhr.responseText,
		  responseXML: responseXML,
		  status: xhr.status,

		  getAllResponseHeaders: function() {
			return xhr.responseHeaders;
			},

		  getResponseHeader: function(name) {
			return obj[name.toLowerCase()];
			}
		  };
		}

	  function nop() {
		}

	  // Entry point
	  opts = addProp({
		type: "GET",
		async: true,
		success: nop,
		error: nop,
		complete: nop
		}, opts);

	  if(typeof GM_xmlhttpRequest === "undefined") {
		setTimeout(function() {
		  var xhr = {};
		  opts.error(xhr, "error");
		  opts.complete(xhr);
		  }, 0);
		return;
		}

	  GM_xmlhttpRequest({
		method: opts.type,
		url: opts.url,
		synchronous: !opts.async,

		onload: function(xhr) {
		  xhr = wrapXhr(xhr);

		  if(xhr.status >= 100 && xhr.status < 300)
			opts.success(xhr.responseXML || xhr.responseText, "success", xhr);
		  else
			opts.error(xhr, "error");

		  opts.complete(xhr);
		  },

		onerror: function(xhr) {
		  xhr = wrapXhr(xhr);
		  opts.error(xhr, "error");
		  opts.complete(xhr);
		  }
		});
	  };

	dom.addEvent = function(e, type, fn) {
	  function mouseEvent(event) {
		if(this != event.relatedTarget && !dom.isAChildOf(this, event.relatedTarget))
		  fn.call(this, event);
		}

	  // Entry point
	  if(e.addEventListener) {
		var effFn = fn;

		if(type == "mouseenter") {
		  type = "mouseover";
		  effFn = mouseEvent;
		  }
		else if(type == "mouseleave") {
		  type = "mouseout";
		  effFn = mouseEvent;
		  }

		e.addEventListener(type, effFn, /*capturePhase*/ false);
		}
	  else
		e.attachEvent("on" + type, function() { fn(win.event); });
	  };

	dom.insertCss = function (styles) {
	  var ss = dom.cE("style");
	  dom.attr(ss, "type", "text/css");

	  var hh = dom.gT("head") [0];
	  dom.append(hh, ss);
	  dom.append(ss, dom.cT(styles));
	  };

	dom.isAChildOf = function(parent, child) {
	  if(parent === child)
		return false;

	  while(child && child !== parent) {
		child = child.parentNode;
		}

	  return child === parent;
	  };

	// -----------------------------------------------------------------------------

	function timeNowInSec() {
	  return Math.round(+new Date() / 1000);
	  }

	function forLoop(opts, fn) {
	  opts = addProp({ start: 0, inc: 1 }, opts);

	  for(var idx = opts.start; idx < opts.num; idx += opts.inc) {
		if(fn.call(opts, idx, opts) === false)
		  break;
		}
	  }

	function forEach(list, fn) {
	  forLoop({ num: list.length }, function(idx) {
		return fn.call(list[idx], idx, list[idx]);
		});
	  }

	function addProp(dest, src) {
	  for(var k in src) {
		if(src[k] != null)
		  dest[k] = src[k];
		}

	  return dest;
	  }

	function inArray(elm, array) {
	  for(var i = 0; i < array.length; ++i) {
		if(array[i] === elm)
		  return i;
		}

	  return -1;
	  }

	function unescHtmlEntities(s) {
	  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
	  }

	function logMsg(s) {
	  win.console.log(s);
	  }

	function cnvSafeFname(s) {
	  s = s.replace(/:/g, "-").replace(/"/g, "'").replace(/[\\/|*?]/g, "_");
	  return encodeURIComponent(s).replace(/'/g, "%27");
	  }

	function getVideoName(s) {
	  var list = [
		{ name: "MOB", codec: "video/3gpp" },
            { name: "FLV", codec: "video/x-flv" },
            { name: "M4V", codec: "video/x-m4v" },
            { name: "MP3", codec: "audio/mpeg" },
            { name: "MP4", codec: "video/mp4" },
            { name: "M4A", codec: "audio/mp4" },
            { name: "QT", codec: "video/quicktime" },
            { name: "WEB", codec: "audio/webm" },
            { name: "WEB", codec: "video/webm" },
            { name: "WMV", codec: "video/ms-wmv" }
		];

	  var name = "?";

	  forEach(list, function(idx, elm) {
		if(s.match("^" + elm.codec)) {
		  name = elm.name;
		  return false;
		  }
		});

	  return name;
	  }

	function snapToStdRes(res) {
        var horzResList = [ 3840, 2048, 1440, 960, 640, 480 ];
        var horzWideResList = [ 2880, 1920, 1280, 854, 640 ];
        var vertResList = [ 2160, 1536, 1080, 720, 480, 360 ];

	  if(!res.match(/^(\d+)x(\d+)/))
		return res;

	  var wd = +RegExp.$1;
	  var ht = +RegExp.$2;
	  var foundIdx;

	  // Snap to the nearest vert res first
	  forEach(vertResList, function(idx, elm) {
		var tolerance = elm * 0.1;
		if(ht >= elm - tolerance && ht <= elm + tolerance) {
		  foundIdx = idx;
		  return false;
		  }
		});

	  if(!foundIdx)
		return res;

	  var aspectRatio = wd >= ht ? wd / ht : ht / wd;

	  ht = vertResList[foundIdx];
	  wd = Math.round(ht * aspectRatio);

	  // Snap to the nearest horz res
	  forEach(aspectRatio < 1536 ? horzResList : horzWideResList, function(idx, elm) {
		var tolerance = elm * 0.1;
		if(wd >= elm - tolerance && wd <= elm + tolerance) {
		  wd = elm;
		  return false;
		  }
		});

	  return wd + "x" + ht;
	  }

	function cnvResName(res) {
	  var resMap = {
		"audio": "Audio"
		};

	  if(resMap[res])
		return resMap[res];

	  if(!res.match(/^(\d+)x(\d+)/))
		return res;

	  var wd = +RegExp.$1;
	  var ht = +RegExp.$2;

	  var vertResMap = {

		};

	  if(vertResMap[ht])
		return vertResMap[ht];

	  var aspectRatio = wd >= ht ? wd / ht : ht / wd;

	  return String(ht) + (aspectRatio < 1.5 ? "f" : "p");
	  }

	function mapResToQuality(res) {
	  if(!res.match(/^[0-9]+x([0-9]+)$/))
		return res;

	  var resList = [
 { res: 2160, q : "2160" },
            { res: 1536, q : "1536" },
            { res: 1080, q: "hd1080" },
            { res: 720, q : "hd720" },
            { res: 480, q : "large" },
            { res: 360, q : "medium" }
		];

	  var res = +RegExp.$1;

	  for(var i = 0; i < resList.length; ++i) {
		if(res >= resList[i].res)
		  return resList[i].q;
		}

	  return "small";
	  }

	function getQualityIdx(quality) {
	  var list = [ "small", "medium", "large", "hd720", "hd1080", "1536", "2160" ];

	  for(var i = 0; i < list.length; ++i) {
		if(list[i] == quality)
		  return i;
		}

	  return -1;
	  }

	// =============================================================================

	RegExp.escape = function(s) {
	  return String(s).replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
	  };

	var decryptSig = {
	  store: {}
	  };

	(function () {

	var SIG_STORE_ID = "ujsYtLinksSig";

	var CHK_SIG_INTERVAL = 3 * 86400;

	decryptSig.load = function() {
	  var obj = localStorage[SIG_STORE_ID];
	  if(obj == null)
		return;

	  decryptSig.store = JSON.parse(obj);
	  };

	decryptSig.save = function() {
	  localStorage[SIG_STORE_ID] = JSON.stringify(decryptSig.store);
	  };

	decryptSig.extractScriptUrl = function(data) {
	  if(data.match(/ytplayer.config\s*=.*\"assets"\s*:\s*{.*"js"\s*:\s*(".+?")/))
		return JSON.parse(RegExp.$1);
	  else
		return false;
	  };

	decryptSig.getScriptName = function(url) {
	  if(url.match(/\/yts\/jsbin\/html5player-(.*)\/html5player\.js$/))
		return RegExp.$1;

	  if(url.match(/\/html5player-(.*)\.js$/))
		return RegExp.$1;

	  return url;
	  };

	decryptSig.fetchScript = function(scriptName, url) {
	  function success(data) {
		if(!data.match(/\.signature\s*=\s*(\w+)\(\w+\)/) && !data.match(/\.set\(\"signature\",(\w+)\(\w+\)\)/) )
		  return;

		//console.log(scriptName + " sig fn: " + RegExp.$1);

		if(!data.match(new RegExp("function " + RegExp.$1 + '\\s*\\((\\w+)\\)\\s*{(\\w+=\\w+\\.split\\(""\\);.+?;return \\w+\\.join\\(""\\))')))
		  return;

		var fnParam = RegExp.$1;
		var fnBody = RegExp.$2;

		var fnHlp = {};
		var objHlp = {};

		//console.log("param: " + fnParam);
		//console.log(fnBody);

		fnBody = fnBody.split(";");

		forEach(fnBody, function(idx, elm) {
		  // its own property
		  if(elm.match(new RegExp("^" + fnParam + "=" + fnParam + "\\.")))
			return;

		  // global fn
		  if(elm.match(new RegExp("^" + fnParam + "=([a-zA-Z_$][a-zA-Z0-9_$]*)\\("))) {
			var name = RegExp.$1;
			//console.log("fnHlp: " + name);

			if(fnHlp[name])
			  return;

			if(data.match(new RegExp("(function " + RegExp.escape(RegExp.$1) + ".+?;return \\w+})")))
			  fnHlp[name] = RegExp.$1;

			return;
			}

		  // object fn
		  if(elm.match(new RegExp("^([a-zA-Z_$][a-zA-Z0-9_$]*)\.([a-zA-Z_$][a-zA-Z0-9_$]*)\\("))) {
			var name = RegExp.$1;
			//console.log("objHlp: " + name);

			if(objHlp[name])
			  return;

			if(data.match(new RegExp("(var " + RegExp.escape(RegExp.$1) + "={.+?};)")))
			  objHlp[name] = RegExp.$1;

			return;
			}
		  });

		//console.log(fnHlp);
		//console.log(objHlp);

		var fnHlpStr = "";

		for(var k in fnHlp)
		  fnHlpStr += fnHlp[k];

		for(var k in objHlp)
		  fnHlpStr += objHlp[k];

		var fullFn = "function(" + fnParam + "){" + fnHlpStr + fnBody.join(";") + "}";
		//console.log(fullFn);

		decryptSig.store[scriptName] = { ver: relInfo.ver, ts: timeNowInSec(), fn: fullFn };
		//console.log(decryptSig);

		decryptSig.save();
		}

	  // Entry point
	  dom.crossAjax({ url: url, success: success });
	  };

	decryptSig.condFetchScript = function(url) {
	  var scriptName = decryptSig.getScriptName(url);
	  var store = decryptSig.store[scriptName];
	  var now = timeNowInSec();

	  if(store && now - store.ts < CHK_SIG_INTERVAL && store.ver == relInfo.ver)
		return;

	  decryptSig.fetchScript(scriptName, url);
	  };

	}) ();

	function deobfuscateVideoSig(scriptName, sig) {
	  if(!decryptSig.store[scriptName])
		return sig;

	  //console.log(decryptSig.store[scriptName].fn);

	  try {
		sig = eval("(" + decryptSig.store[scriptName].fn + ") (\"" + sig + "\")");
	  } catch(e) {
		}

	  return sig;
	  }

	// =============================================================================

	function parseStreamMap(map, value) {
	  var fmtUrlList = [];

	  forEach(value.split(","), function(idx, elm) {
		var elms = elm.replace(/\\\//g, "/").replace(/\\u0026/g, "&").split("&");
		var obj = {};

		forEach(elms, function(idx, elm) {
		  var kv = elm.split("=");
		  obj[kv[0]] = decodeURIComponent(kv[1]);
		  });

		obj.itag = +obj.itag;

		if(obj.conn != null && obj.conn.match(/^rtmpe:\/\//))
		  obj.isDrm = true;

		if(obj.s != null && obj.sig == null) {
		  var sig = deobfuscateVideoSig(map.scriptName, obj.s);
		  if(sig != obj.s) {
			obj.sig = sig;
			delete obj.s;
			}
		  }

		fmtUrlList.push(obj);
		});

	  //logMsg(fmtUrlList);

	  map.fmtUrlList = fmtUrlList;
	  }

	function parseAdaptiveStreamMap(map, value) {
	  var fmtUrlList = [];

	  forEach(value.split(","), function(idx, elm) {
		var elms = elm.replace(/\\\//g, "/").replace(/\\u0026/g, "&").split("&");
		var obj = {};

		forEach(elms, function(idx, elm) {
		  var kv = elm.split("=");
		  obj[kv[0]] = decodeURIComponent(kv[1]);
		  });

		obj.itag = +obj.itag;

		if(obj.bitrate != null)
		  obj.bitrate = +obj.bitrate;

		if(obj.clen != null)
		  obj.clen = +obj.clen;

		if(obj.fps != null)
		  obj.fps = +obj.fps;

		//logMsg(obj);
		//logMsg(map.videoId + ": " + obj.index + " " + obj.init + " " + obj.itag + " " + obj.size + " " + obj.bitrate + " " + obj.type);

		if(obj.type.match(/^video\/mp4/))
		  obj.effType = "video/x-m4v";

		if(obj.type.match(/^audio\//))
		  obj.size = "audio";

		var stdRes = snapToStdRes(obj.size);
		obj.quality = mapResToQuality(stdRes);

		if(obj.s != null && obj.sig == null) {
		  var sig = deobfuscateVideoSig(map.scriptName, obj.s);
		  if(sig != obj.s) {
			obj.sig = sig;
			delete obj.s;
			}
		  }

		fmtUrlList.push(obj);

		map.fmtMap[obj.itag] = { res: cnvResName(stdRes) };
		});

	  map.fmtUrlList = map.fmtUrlList.concat(fmtUrlList);
	  }

	function parseFmtList(map, value) {
	  var list = value.split(",");
	  var fmtMap = {};

	  forEach(list, function(idx, elm) {
		var elms = elm.replace(/\\\//g, "/").split("/");

		var fmtId = elms[0];
		var res = elms[1];
		elms.splice(/*idx*/ 0, /*rm*/ 2);

		fmtMap[fmtId] = { res: cnvResName(res), vars: elms };
		});

	  map.fmtMap = fmtMap;
	  }

	function getExt(elm) {
	  return "";
	  }

	function getVideoInfo(url, callback) {
	  function success(data) {
		var map = {};

		if(data.match(/<div\s+id="verify-details">/)) {
		  logMsg("Skipping " + url);
		  return;
		  }

		if(data.match(/<h1\s+id="unavailable-message">/)) {
		  logMsg("Not avail " + url);
		  return;
		  }

		if(data.match(/"t":\s?"(.+?)"/))
		  map.t = RegExp.$1;

		if(data.match(/"video_id":\s?"(.+?)"/))
		  map.videoId = RegExp.$1;

		map.scriptUrl = decryptSig.extractScriptUrl(data);
		if(map.scriptUrl) {
		  //logMsg(map.videoId + " script: " + map.scriptUrl);
		  map.scriptName = decryptSig.getScriptName(map.scriptUrl);
		  decryptSig.condFetchScript(map.scriptUrl);
		  }

		if(data.match(/<meta\s+itemprop="name"\s*content="(.+)"\s*>\s*\n/))
		  map.title = unescHtmlEntities(RegExp.$1);

		if(map.title == null && data.match(/<meta\s+name="title"\s*content="(.+)"\s*>/))
		  map.title = unescHtmlEntities(RegExp.$1);

		if(data.match(/"url_encoded_fmt_stream_map":\s?"(.+?)"/))
		  parseStreamMap(map, RegExp.$1);

		if(data.match(/"fmt_list":\s?"(.+?)"/))
		  parseFmtList(map, RegExp.$1);

		if(data.match(/"adaptive_fmts":\s?"(.+?)"/))
		  parseAdaptiveStreamMap(map, RegExp.$1);

		if(data.match(/"dashmpd":\s?"(.+?)"/))
		  map.dashmpd = decodeURIComponent(RegExp.$1.replace(/\\\//g, "/"));

		if(userConfig.filteredFormats.length > 0) {
		  for(var i = 0; i < map.fmtUrlList.length; ++i) {
			if(inArray(getVideoName(map.fmtUrlList[i].effType || map.fmtUrlList[i].type), userConfig.filteredFormats) >= 0) {
			  map.fmtUrlList.splice(i, /*len*/ 1);
			  --i;
			  continue;
			  }
			}
		  }

		var hasHighRes = false;
		var hasHighAudio = false;
		var HIGH_AUDIO_BPS = 96 * 1024;

		forEach(map.fmtUrlList, function(idx, elm) {
		  hasHighRes |= elm.quality == "720" || elm.quality == "1080";

		  if(elm.quality == "audio")
			hasHighAudio |= elm.bitrate >= HIGH_AUDIO_BPS;
		  });

		if(hasHighRes) {
		  for(var i = 0; i < map.fmtUrlList.length; ++i) {
			if(map.fmtUrlList[i].quality == "small") {
			  map.fmtUrlList.splice(i, /*len*/ 1);
			  --i;
			  continue;
			  }
			}
		  }

		if(hasHighAudio) {
		  for(var i = 0; i < map.fmtUrlList.length; ++i) {
			if(map.fmtUrlList[i].quality == "audio" && map.fmtUrlList[i].bitrate < HIGH_AUDIO_BPS) {
			  map.fmtUrlList.splice(i, /*len*/ 1);
			  --i;
			  continue;
			  }
			}
		  }

		map.fmtUrlList.sort(cmpUrlList);

		callback(map);
		}

	  // Entry point
	  dom.ajax({ url: url, success: success });
	  }

	function cmpUrlList(a, b) {
	  var diff = getQualityIdx(b.quality) - getQualityIdx(a.quality);
	  if(diff != 0)
		return diff;

	  var aRes = (a.size || "").match(/^(\d+)x(\d+)/);
	  var bRes = (b.size || "").match(/^(\d+)x(\d+)/);

	  if(aRes == null) aRes = [ 0, 0, 0 ];
	  if(bRes == null) bRes = [ 0, 0, 0 ];

	  return +bRes[2] - +aRes[2];
	  }

	// -----------------------------------------------------------------------------

var CSS_PREFIX = "ujs-";
    
    var HDR_LINKS_HTML_ID = CSS_PREFIX + "hdr-links-div";
    var LINKS_HTML_ID = CSS_PREFIX + "links-cls";
    var LINKS_TP_HTML_ID = CSS_PREFIX + "links-tp-div";
    var UPDATE_HTML_ID = CSS_PREFIX + "update-div";
    var VID_FMT_BTN_ID = CSS_PREFIX + "vid-fmt-btn";
    
    /* The !important attr is to override the page's specificity. */
    var CSS_STYLES =
        "#" + VID_FMT_BTN_ID + dom.emitCssStyles({
            "margin": "0 0.333em"
        }) + "\n" +
        "#" + UPDATE_HTML_ID + dom.emitCssStyles({
            "background-color": "#f00",
            "border-radius": "2px",
            "color": "#fff",
            "padding": "5px",
            "text-align": "center",
            "text-decoration": "none",
            "position": "fixed",
            "top": "0.5em",
            "right": "5em",
            "z-index": "100"
        }) + "\n" +
        "#" + UPDATE_HTML_ID + ":hover" + dom.emitCssStyles({
            "background-color": "#0d0"
        }) + "\n" +
        "#" + HDR_LINKS_HTML_ID + dom.emitCssStyles({
            "font-size": "100%",
        }) + "\n" +
        "#" + HDR_LINKS_HTML_ID + " ." + CSS_PREFIX + "group" + dom.emitCssStyles({
            "background-color": "#fff",
            "color": "#000 !important",
            "border": "#ccc 1px solid",
            "border-radius": "3px",
            "display": "inline-block",
            "margin": "3px",
        }) + "\n" +
        "#" + HDR_LINKS_HTML_ID + " a" + dom.emitCssStyles({
            "display": "table-cell",
            "padding": "3px",
            "text-decoration": "none"
        }) + "\n" +
        "#" + HDR_LINKS_HTML_ID + " a:hover" + dom.emitCssStyles({
            "background-color": "#d1e1fa"
        }) + "\n" +
        "div." + LINKS_HTML_ID + dom.emitCssStyles({
            "border-radius": "3px",
            "font-size": "80%",
            "position": "absolute",
            "left": "0",
            "top": "0.2em",
            "z-index": "100"
        }) + "\n" +
        "#" + LINKS_TP_HTML_ID + dom.emitCssStyles({
            "background-color": "#eee",
            "border": "#aaa 1px solid",
            "padding": "3px 0",
            "text-decoration": "none",
            "white-space": "nowrap",
            "z-index": "100"
        }) + "\n" +
        "div." + LINKS_HTML_ID + " a" + dom.emitCssStyles({
            "display": "inline-block",
            "margin": "1px",
            "text-decoration": "none"
        }) + "\n" +
        "div." + LINKS_HTML_ID + " ." + CSS_PREFIX + "video" + dom.emitCssStyles({
            "display": "inline-block",
            "text-align": "center",
            "width": "3em"
        }) + "\n" +
        "div." + LINKS_HTML_ID + " ." + CSS_PREFIX + "quality" + dom.emitCssStyles({
            "display": "inline-block",
            "text-align": "center",
            "width": "3em"
        }) + "\n" +
        "." + CSS_PREFIX + "video" + dom.emitCssStyles({
            "color": "#fff !important",
            "padding": "2.5px 10px",
            "text-align": "center"
        }) + "\n" +
        "." + CSS_PREFIX + "quality" + dom.emitCssStyles({
            "color": "#000 !important",
            "padding": "2.5px 3px",
            "vertical-align": "middle"
        }) + "\n" +
        "." + CSS_PREFIX + "filesize" + dom.emitCssStyles({
            "font-size": "90%",
            "margin-top": "2px",
            "padding": "1px 3px",
            "text-align": "center"
        }) + "\n" +
        "." + CSS_PREFIX + "filesize-err" + dom.emitCssStyles({
            "color": "#f00",
            "font-size": "90%",
            "margin-top": "2px",
            "padding": "1px 3px",
            "text-align": "center"
        }) + "\n" +
        "." + CSS_PREFIX + "not-avail" + dom.emitCssStyles({
            "background-color": "#700",
            "color": "#fff",
            "padding": "3px",
        }) + "\n" +
        "." + CSS_PREFIX + "mob" + dom.emitCssStyles({
            "background-color": "#956D03"
        }) + "\n" +
        "." + CSS_PREFIX + "flv" + dom.emitCssStyles({
            "background-color": "#0dd"
        }) + "\n" +
        "." + CSS_PREFIX + "m4a" + dom.emitCssStyles({
            "background-color": "#07e"
        }) + "\n" +
        "." + CSS_PREFIX + "m4v" + dom.emitCssStyles({
            "background-color": "#07e"
        }) + "\n" +
        "." + CSS_PREFIX + "mp3" + dom.emitCssStyles({
            "background-color": "#7ba"
        }) + "\n" +
        "." + CSS_PREFIX + "mp4" + dom.emitCssStyles({
            "background-color": "#777"
        }) + "\n" +
        "." + CSS_PREFIX + "qt" + dom.emitCssStyles({
            "background-color": "#f08"
        }) + "\n" +
        "." + CSS_PREFIX + "web" + dom.emitCssStyles({
            "background-color": "#e0e"
        }) + "\n" +
        "." + CSS_PREFIX + "wmv" + dom.emitCssStyles({
            "background-color": "#c75"
        }) + "\n" +
        "." + CSS_PREFIX + "small" + dom.emitCssStyles({
            "color": "#888 !important",
        }) + "\n" +
        "." + CSS_PREFIX + "medium" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#0d0"
        }) + "\n" +
        "." + CSS_PREFIX + "large" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#00d",
            "background-image": "linear-gradient(to right, #00d, #00a)"
        }) + "\n" +
        "." + CSS_PREFIX + "hd720" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#f90",
            "background-image": "linear-gradient(to right, #f90, #d70)"
        }) + "\n" +
        "." + CSS_PREFIX + "hd1080" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#f00",
            "background-image": "linear-gradient(to right, #f00, #c00)"
        }) + "\n" +
        "." + CSS_PREFIX + "1536" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#c0f",
        }) + "\n" +
        "." + CSS_PREFIX + "2160" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#000",
        }) + "\n" +
        "." + CSS_PREFIX + "audio" + dom.emitCssStyles({
            "color": "#fff !important",
            "background-color": "#909600",
        }) + "\n" +
        "." + CSS_PREFIX + "pos-rel" + dom.emitCssStyles({
            "position": "relative"
        }) + "\n" +
        "";

	function condInsertHdr(divId) {
	  if(dom.gE(HDR_LINKS_HTML_ID))
		return true;

	  var insertPtNode = dom.gE(divId);
	  if(!insertPtNode)
		return false;

	  var divNode = dom.cE("div");
	  divNode.id = HDR_LINKS_HTML_ID;

	  insertPtNode.parentNode.insertBefore(divNode, insertPtNode);
	  return true;
	  }

	function condInsertTooltip() {
	  if(dom.gE(LINKS_TP_HTML_ID))
		return true;

	  var toolTipNode = dom.cE("div");
	  toolTipNode.id = LINKS_TP_HTML_ID;

	  dom.attr(toolTipNode, "class", LINKS_HTML_ID);
	  dom.attr(toolTipNode, "style", "display: none;");

	  dom.append(doc.body, toolTipNode);

	  dom.addEvent(toolTipNode, "mouseleave", function(event) {
		//logMsg("mouse leave");
		dom.attr(toolTipNode, "style", "display: none;");
		});
	  }

	function condInsertUpdateIcon() {
	  if(dom.gE(UPDATE_HTML_ID))
		return;

	  var divNode = dom.cE("a");
	  divNode.id = UPDATE_HTML_ID;
	  dom.append(doc.body, divNode);
	  }

	// -----------------------------------------------------------------------------

	var STORE_ID = "ujsYtLinks";
	var JSONP_ID = "ujsYtLinks";

	var userConfig = {
	  filteredFormats: [],
	  showVideoFormats: true,
	  showVideoSize: true,
	  tagLinks: true
	  };

	var videoInfoCache = {};

	var TAG_LINK_NUM_PER_BATCH = 5;
	var INI_TAG_LINK_DELAY_MS = 100;
	var SUB_TAG_LINK_DELAY_MS = 150;

	// -----------------------------------------------------------------------------

	function Links() {
	  }

	Links.prototype.init = function() {
	  };

	Links.prototype.getPreferredFmt = function(map) {
	  var selElm = map.fmtUrlList[0];

	  forEach(map.fmtUrlList, function(idx, elm) {
		if(getVideoName(elm.type).toLowerCase() != "webm") {
		  selElm = elm;
		  return false;
		  }
		});

	  return selElm;
	  };

	Links.prototype.parseDashManifest = function(map) {
	  function parse(xml) {
		//logMsg(xml);

		var dashList = [];

		var adaptationSetDom = xml.getElementsByTagName("AdaptationSet");
		//logMsg(adaptationSetDom);

		forEach(adaptationSetDom, function(i, adaptationElm) {
		  var mimeType = adaptationElm.getAttribute("mimeType");
		  //logMsg(i + " " + mimeType);

		  var representationDom = adaptationElm.getElementsByTagName("Representation");
		  forEach(representationDom, function(j, repElm) {
			var dashElm = { mimeType: mimeType };

			forEach([ "codecs" ], function(idx, elm) {
			  var v = repElm.getAttribute(elm);
			  if(v != null)
				dashElm[elm] = v;
			  });

			forEach([ "audioSamplingRate", "bandwidth", "frameRate", "height", "id", "width" ], function(idx, elm) {
			  var v = repElm.getAttribute(elm);
			  if(v != null)
				dashElm[elm] = +v;
			  });

			var baseUrlDom = repElm.getElementsByTagName("BaseURL");
			dashElm.len = +baseUrlDom[0].getAttribute("yt:contentLength");
			dashElm.url = baseUrlDom[0].textContent;

			dashList.push(dashElm);
			});
		  });

		//logMsg(map);
		//logMsg(dashList);

		var maxBitRateMap = {};

		forEach(dashList, function(idx, dashElm) {
		  if(dashElm.mimeType != "video/mp4" && dashElm.mimeType != "video/webm")
			return;

		  var id = [ dashElm.mimeType, dashElm.width, dashElm.height, dashElm.frameRate ].join("|");

		  if(maxBitRateMap[id] == null || maxBitRateMap[id] < dashElm.bandwidth)
			maxBitRateMap[id] = dashElm.bandwidth;
		  });

		forEach(dashList, function(idx, dashElm) {
		  var foundIdx;

		  forEach(map.fmtUrlList, function(idx, mapElm) {
			if(dashElm.id == mapElm.itag) {
			  foundIdx = idx;
			  return false;
			  }
			});

		  if(foundIdx != null)
			return;

		  //logMsg(dashElm);

		  if((dashElm.mimeType == "video/mp4" || dashElm.mimeType == "video/webm") && (dashElm.width >= 1000 || dashElm.height >= 1000)) {
			var id = [ dashElm.mimeType, dashElm.width, dashElm.height, dashElm.frameRate ].join("|");

			if(maxBitRateMap[id] == null || dashElm.bandwidth < maxBitRateMap[id])
			  return;

			var size = dashElm.width + "x" + dashElm.height;
			var stdRes = snapToStdRes(size);

			if(map.fmtMap[dashElm.id] == null) {
			  map.fmtMap[dashElm.id] = { res: cnvResName(stdRes) };
			  }

			map.fmtUrlList.push({
			  bitrate: dashElm.bandwidth,
			  effType: dashElm.mimeType == "video/mp4" ? "video/x-m4v" : null,
			  filesize: dashElm.len,
			  fps: dashElm.frameRate,
			  itag: dashElm.id,
			  quality: mapResToQuality(stdRes),
			  size: size,
			  type: dashElm.mimeType + ";+codecs=\"" + dashElm.codecs + "\"",
			  url: dashElm.url
			  });
			}
		  else if(dashElm.mimeType == "audio/mp4" && dashElm.audioSamplingRate >= 44100) {
			if(map.fmtMap[dashElm.id] == null) {
			  map.fmtMap[dashElm.id] = { res: "Audio" };
			  }

			map.fmtUrlList.push({
			  bitrate: dashElm.bandwidth,
			  filesize: dashElm.len,
			  itag: dashElm.id,
			  quality: "audio",
			  type: dashElm.mimeType + ";+codecs=\"" + dashElm.codecs + "\"",
			  url: dashElm.url
			  });
			}
		  });

		if(condInsertHdr("page"))
		  dom.html(dom.gE(HDR_LINKS_HTML_ID), me.emitLinks(map));
		}

	  // Entry point
	  var me = this;

	  if(!map.dashmpd)
		return;

	  //logMsg(map.dashmpd);

	  if(map.dashmpd.match(/\/s\/([a-zA-Z0-9.]+)\//)) {
		var sig = deobfuscateVideoSig(map.scriptName, RegExp.$1);
		map.dashmpd = map.dashmpd.replace(/\/s\/[a-zA-Z0-9.]+\//, "/signature/" + sig + "/");
		}

	  dom.crossAjax({
		url: map.dashmpd,
		dataType: "xml",

		success: function(data, status, xhr) {
		  parse(data);
		  },

		error: function(xhr, status) {
		  },

		complete: function(xhr) {
		  }
		});
	  };

	Links.prototype.checkFmts = function(forceFlag) {
	  var me = this;

	  if(!userConfig.showVideoFormats)
		return;

	  if(!forceFlag && userConfig.showVideoFormats == "btn") {
		if(dom.gE(VID_FMT_BTN_ID))
		  return;

		var btn = dom.cE("button");
		dom.attr(btn, "id", VID_FMT_BTN_ID);
		dom.attr(btn, "class", "yt-uix-button yt-uix-button-default");
		btn.innerHTML = "VidFmts";

		var mastH = dom.gE("yt-masthead-signin") || dom.gE("yt-masthead-user");
		if(!mastH)
		  return;

		dom.prepend(mastH, btn);

		dom.addEvent(btn, "click", function(event) {
		  me.checkFmts(/*force*/ true);
		  });

		return;
		}

	  if(!loc.href.match(/watch\?v=([a-zA-Z0-9_-]*)/))
		return false;

	  var videoId = RegExp.$1;

	  var url = loc.protocol + "//" + loc.host + "/watch?v=" + videoId;

	  getVideoInfo(url, function(map) {
		me.parseDashManifest(map);
		me.showLinks("page", map);
		});
	  };

	Links.prototype.genUrl = function(map, elm) {
	  var url = elm.url + "&title=" + cnvSafeFname(map.title + getExt(elm));

	  if(elm.sig != null)
		url += "&signature=" + elm.sig;

	  return url;
	  };

	Links.prototype.emitLinks = function(map) {
	  function fmtSize(size, units) {
		if(!units)
		  units = [ "kB", "MB", "GB" ];

		for(var idx = 0; idx < units.length; ++idx) {
		  size /= 1000;

		  if(size < 10)
			return Math.round(size * 100) / 100 + units[idx];

		  if(size < 100)
			return Math.round(size * 10) / 10 + units[idx];

		  if(size < 1000 || idx == units.length - 1)
			return Math.round(size) + units[idx];
		  }
		}

	  function fmtBitrate(size) {
		return fmtSize(size, [ "kbps", "Mbps", "Gbps" ]);
		}

	  // Entry point
	  var me = this;
	  var s = [];

	  var resMap = {};

	  map.fmtUrlList.sort(cmpUrlList);

	  forEach(map.fmtUrlList, function(idx, elm) {
		var fmtMap = map.fmtMap[elm.itag];

		if(!resMap[fmtMap.res]) {
		  resMap[fmtMap.res] = [];
		  resMap[fmtMap.res].quality = elm.quality;
		  }

		resMap[fmtMap.res].push(elm);
		});

	  for(var res in resMap) {
		var qFields = [];

		qFields.push(dom.emitHtml("div", { "class": CSS_PREFIX + "quality " + CSS_PREFIX + resMap[res].quality }, res));

		forEach(resMap[res], function(idx, elm) {
		  var fields = [];
		  var fmtMap = map.fmtMap[elm.itag];
		  var videoName = getVideoName(elm.effType || elm.type);

		  var addMsg = [ elm.itag, elm.type, elm.size || elm.quality ];

		  if(elm.fps != null)
			addMsg.push(elm.fps + "fps");

		  var varMsg = "";

		  if(elm.bitrate != null)
			varMsg = fmtBitrate(elm.bitrate);
		  else if(fmtMap.vars != null)
			varMsg = fmtMap.vars.join();

		  addMsg.push(varMsg);

		  if(elm.s != null)
			addMsg.push("sig-" + elm.s.length);

		  if(elm.filesize != null && elm.filesize >= 0)
			addMsg.push(fmtSize(elm.filesize));

		  var vidSuffix = "";

		  if(inArray(elm.itag, [ 5, 17, 18, 22, 36, 43, 171 ]) >= 0)
			vidSuffix = " * ";
		  else if(elm.fps != null && elm.fps >= 45)
			vidSuffix = " (HFR)";

		  fields.push(dom.emitHtml("div", { "class": CSS_PREFIX + "video " + CSS_PREFIX + videoName.toLowerCase() }, videoName + vidSuffix));

		  if(elm.filesize != null) {
			if(elm.filesize >= 0) {
			  fields.push(dom.emitHtml("div", { "class": CSS_PREFIX + "filesize" }, fmtSize(elm.filesize)));
			  }
			else {
			  var msg;

			  if(elm.isDrm)
				msg = "DRM";
			  else if(elm.s != null)
				msg = "sig-" + elm.s.length;
			  else
				msg = "Err";

			  fields.push(dom.emitHtml("div", { "class": CSS_PREFIX + "filesize-err" }, msg));
			  }
			}

		  var url;

		  if(elm.isDrm)
			url = elm.conn + "?" + elm.stream;
		  else
			url = me.genUrl(map, elm);

		  var ahref = dom.emitHtml("a", {
			href: url,
			title: addMsg.join(" | ")
			}, fields.join(""));

		  qFields.push(ahref);
		  });

		s.push(dom.emitHtml("div", { "class": CSS_PREFIX + "group" }, qFields.join("")));
		}

	  return s.join("");
	  };

	var INI_SHOW_FILESIZE_DELAY_MS = 100;
	var SUB_SHOW_FILESIZE_DELAY_MS = 150;

	Links.prototype.showLinks = function(divId, map) {
	  function updateLinks() {
		//!! Hack to update file size
		if(condInsertHdr(divId))
		  dom.html(dom.gE(HDR_LINKS_HTML_ID), me.emitLinks(map));
		}

	  // Entry point
	  var me = this;

	  // video is not avail
	  if(!map.fmtUrlList)
		return;

	  //logMsg(JSON.stringify(map));

	  if(!condInsertHdr(divId))
		return;

	  dom.html(dom.gE(HDR_LINKS_HTML_ID), me.emitLinks(map));

	  if(!userConfig.showVideoSize)
		return;

	  forEach(map.fmtUrlList, function(idx, elm) {
		//logMsg(elm.itag + " " + elm.url);

		// We just fail outright for protected/obfuscated videos
		if(elm.isDrm || elm.s != null) {
		  elm.filesize = -1;
		  updateLinks();
		  return;
		  }

		if(elm.clen != null) {
		  elm.filesize = elm.clen;
		  updateLinks();
		  return;
		  }

		setTimeout(function() {
		  dom.crossAjax({
			type: "HEAD",
			url: me.genUrl(map, elm),

			success: function(data, status, xhr) {
			  var filesize = xhr.getResponseHeader("Content-Length");
			  if(filesize == null)
				return;

			  //logMsg(map.title + " " + elm.itag + ": " + filesize);
			  elm.filesize = +filesize;

			  updateLinks();
			  },

			error: function(xhr, status) {
			  //logMsg(map.fmtMap[elm.itag].res + " " + getVideoName(elm.type) + ": " + xhr.status);

			  if(xhr.status != 403)
				return;

			  elm.filesize = -1;

			  updateLinks();
			  },

			complete: function(xhr) {
			  //logMsg(map.title + ": " + xhr.getAllResponseHeaders());
			  }
			});
		   }, INI_SHOW_FILESIZE_DELAY_MS + idx * SUB_SHOW_FILESIZE_DELAY_MS);
		});
	  };

	Links.prototype.tagLinks = function() {
	  var SCANNED = 1;
	  var REQ_INFO = 2;
	  var ADDED_INFO = 3;

	  function prepareTagHtml(node, map) {
		var elm = me.getPreferredFmt(map);
		var fmtMap = map.fmtMap[elm.itag];

		dom.attr(node, "class", LINKS_HTML_ID + " " + CSS_PREFIX + "quality " + CSS_PREFIX + elm.quality);

		dom.addEvent(node, "mouseenter", function(event) {
		  //logMsg("mouse enter " + map.videoId);
		  var pos = dom.offset(node);
		  //logMsg("mouse enter: x " + pos.left + ", y " + pos.top);

		  var toolTipNode = dom.gE(LINKS_TP_HTML_ID);

		  dom.attr(toolTipNode, "style", "position: absolute; left: " + pos.left + "px; top: " + pos.top + "px");

		  dom.html(toolTipNode, me.emitLinks(map));
		  });

		node.href = elm.url + "&title=" + cnvSafeFname(map.title + getExt(elm));

		return fmtMap.res;
		}

	  function addTag(hNode, map) {
		//logMsg(dom.html(hNode));
		//logMsg("hNode " + dom.attr(hNode, "class"));
		//var img = dom.gT(hNode, "img") [0];
		//logMsg(dom.attr(img, "src"));
		//logMsg(dom.attr(img, "class"));

		dom.attr(hNode, CSS_PREFIX + "processed", ADDED_INFO);

		var node = dom.cE("div");

		if(map.fmtUrlList) {
		  tagHtml = prepareTagHtml(node, map);
		  }
		else {
		  dom.attr(node, "class", LINKS_HTML_ID + " " + CSS_PREFIX + "not-avail");
		  tagHtml = "NA";
		  }

		var parentNode;
		var insNode;

		var cls = dom.attr(hNode, "class") || "";
		var isVideoWallStill = cls.match(/videowall-still/);
		if(isVideoWallStill) {
		  parentNode = hNode;
		  insNode = hNode.firstChild;
		  }
		else {
		  parentNode = hNode.parentNode;
		  insNode = hNode;
		  }

		var parentCssPositionStyle = window.getComputedStyle(parentNode, null).getPropertyValue("position");

		if(parentCssPositionStyle != "absolute" && parentCssPositionStyle != "relative")
		  dom.attr(parentNode, "class", dom.attr(parentNode, "class") + " " + CSS_PREFIX + "pos-rel");

		parentNode.insertBefore(node, insNode);

		dom.html(node, tagHtml);
		}

	  function getFmt(videoId, hNode) {
		if(videoInfoCache[videoId]) {
		  addTag(hNode, videoInfoCache[videoId]);
		  return;
		  }

		var url;

		if(videoId.match(/.+==$/))
		  url = loc.protocol + "//" + loc.host + "/cthru?key=" + videoId;
		else
		  url = loc.protocol + "//" + loc.host + "/watch?v=" + videoId;

		getVideoInfo(url, function(map) {
		  videoInfoCache[videoId] = map;
		  addTag(hNode, map);
		  });
		}

	  // Entry point
	  var me = this;

	  var list = [];

	  forEach(dom.gT("a"), function(idx, hNode) {
		if(dom.attr(hNode, CSS_PREFIX + "processed"))
		  return;

		if(!dom.inViewport(hNode))
		  return;

		dom.attr(hNode, CSS_PREFIX + "processed", SCANNED);

		if(!hNode.href.match(/watch\?v=([a-zA-Z0-9_-]*)/) &&
		 !hNode.href.match(/watch_videos.+?&video_ids=([a-zA-Z0-9_-]*)/))
		  return;

		var videoId = RegExp.$1;

		var cls = dom.attr(hNode, "class") || "";
		if(!cls.match(/videowall-still/)) {
		  if(cls == "yt-button" || cls.match(/yt-uix-button/))
			return;

		  if(dom.attr(hNode.parentNode, "class") == "video-time")
			return;

		  if(dom.html(hNode).match(/video-logo/i))
			return;

		  var img = dom.gT(hNode, "img");
		  if(img == null || img.length == 0)
			return;

		  img = img[0];

		  var imgSrc = dom.attr(img, "src") || "";
		  if(imgSrc.indexOf("ytimg.com") < 0)
			return;

		  var tnSrc = dom.attr(img, "thumb") || "";

		  if(imgSrc.match(/.+?\/([a-zA-Z0-9_-]*)\/default\.jpg$/))
			videoId = RegExp.$1;
		  else if(tnSrc.match(/.+?\/([a-zA-Z0-9_-]*)\/default\.jpg$/))
			videoId = RegExp.$1;
		  }

		//logMsg(idx + " " + hNode.href);
		//logMsg("videoId: " + videoId);

		list.push({ videoId: videoId, hNode: hNode });

		dom.attr(hNode, CSS_PREFIX + "processed", REQ_INFO);
		});

	  forLoop({ num: list.length, inc: TAG_LINK_NUM_PER_BATCH, batchIdx: 0 }, function(idx) {
		var batchIdx = this.batchIdx++;
		var batchList = list.slice(idx, idx + TAG_LINK_NUM_PER_BATCH);

		setTimeout(function() {
		  forEach(batchList, function(idx, elm) {
			//logMsg(batchIdx + " " + idx + " " + elm.hNode.href);
			getFmt(elm.videoId, elm.hNode);
			});
		  }, INI_TAG_LINK_DELAY_MS + batchIdx * SUB_TAG_LINK_DELAY_MS);
		});
	  };

	Links.prototype.periodicTagLinks = function(delayMs) {
	  function poll() {
		me.tagLinks();
		me.tagLinksTimerId = setTimeout(poll, 3000);
		}

	  // Entry point
	  if(!userConfig.tagLinks)
		return;

	  var me = this;

	  delayMs = delayMs || 0;

	  if(me.tagLinksTimerId != null) {
		clearTimeout(me.tagLinksTimerId);
		delete me.tagLinksTimerId;
		}

	  setTimeout(poll, delayMs);
	  };

	// -----------------------------------------------------------------------------

	Links.prototype.loadSettings = function() {
	  var obj = localStorage[STORE_ID];
	  if(obj == null)
		return;

	  obj = JSON.parse(obj);

	  this.lastChkReqTs = +obj.lastChkReqTs;
	  this.lastChkTs = +obj.lastChkTs;
	  this.lastChkVer = +obj.lastChkVer;
	  };

	Links.prototype.storeSettings = function() {
	  localStorage[STORE_ID] = JSON.stringify({
		lastChkReqTs: this.lastChkReqTs,
		lastChkTs: this.lastChkTs,
		lastChkVer: this.lastChkVer
		});
	  };

	// -----------------------------------------------------------------------------

	var UPDATE_CHK_INTERVAL = 5 * 86400;
	var FAIL_TO_CHK_UPDATE_INTERVAL = 14 * 86400;

	Links.prototype.chkVer = function(forceFlag) {
	  if(this.lastChkVer > relInfo.ver) {
		this.showNewVer({ ver: this.lastChkVer });
		return;
		}

	  var now = timeNowInSec();

	  //logMsg("lastChkReqTs " + this.lastChkReqTs + ", diff " + (now - this.lastChkReqTs));
	  //logMsg("lastChkTs " + this.lastChkTs);
	  //logMsg("lastChkVer " + this.lastChkVer);

	  if(this.lastChkReqTs == null || now < this.lastChkReqTs) {
		this.lastChkReqTs = now;
		this.storeSettings();
		return;
		}

	  if(now - this.lastChkReqTs < UPDATE_CHK_INTERVAL)
		return;

	  if(this.lastChkReqTs - this.lastChkTs > FAIL_TO_CHK_UPDATE_INTERVAL)
		logMsg("Failed to check ver for " + ((this.lastChkReqTs - this.lastChkTs) / 86400) + " days");

	  this.lastChkReqTs = now;
	  this.storeSettings();

	  unsafeWin[JSONP_ID] = this;

	  var script = dom.cE("script");
	  script.type = "text/javascript";
	  script.src  = SCRIPT_UPDATE_LINK;
	  dom.append(doc.body, script);
	  };

	Links.prototype.chkVerCallback = function(data) {
	  delete unsafeWin[JSONP_ID];

	  this.lastChkTs = timeNowInSec();
	  this.storeSettings();

	  //logMsg(JSON.stringify(data));

	  var latestElm = data[0];

	  if(latestElm.ver <= relInfo.ver)
		return;

	  this.showNewVer(latestElm);
	  };

	Links.prototype.showNewVer = function(latestElm) {
	  function getVerStr(ver) {
		var verStr = "" + ver;

		var majorV = verStr.substr(0, verStr.length - 4) || "0";
		var minorV = verStr.substr(verStr.length - 4, 2);
		return majorV + "." + minorV;
		}

	  // Entry point
	  this.lastChkVer = latestElm.ver;
	  this.storeSettings();

	  condInsertUpdateIcon();

	  var aNode = dom.gE(UPDATE_HTML_ID);

	  aNode.href = SCRIPT_LINK;

	  if(latestElm.desc != null)
		dom.attr(aNode, "title", latestElm.desc);

	  dom.html(aNode, dom.emitHtml("b", SCRIPT_NAME + " " + getVerStr(relInfo.ver)) +
	   "<br>Click to update to " + getVerStr(latestElm.ver));
	  };

	// -----------------------------------------------------------------------------

	var inst = new Links();

	inst.init();
	inst.loadSettings();
	decryptSig.load();

	dom.insertCss(CSS_STYLES);

	condInsertTooltip();

	if(loc.pathname.match(/\/watch/)) {
	  inst.checkFmts();
	  }

	inst.periodicTagLinks();

	var scrollTop = win.pageYOffset || doc.documentElement.scrollTop;

	dom.addEvent(win, "scroll", function(e) {
	  var newScrollTop = win.pageYOffset || doc.documentElement.scrollTop;

	  if(Math.abs(newScrollTop - scrollTop) < 100)
		return;

	  //logMsg("scroll by " + (newScrollTop - scrollTop));

	  scrollTop = newScrollTop;

	  inst.periodicTagLinks(200);
	  });

	inst.chkVer();

	// -----------------------------------------------------------------------------

	/* YouTube reuses the current page when the user clicks on a new video. We need
	 to detect it and reload the formats. */

	(function() {

	var PERIODIC_CHK_VIDEO_URL_MS = 100;

	var curVideoUrl = loc.toString();

	function periodicChkVideoUrl() {
	  var newVideoUrl = loc.toString();

	  if(curVideoUrl != newVideoUrl) {
		//logMsg(curVideoUrl + " -> " + newVideoUrl);

		curVideoUrl = newVideoUrl;

		if(loc.pathname.match(/\/watch/))
		  inst.checkFmts();
		}

	  setTimeout(periodicChkVideoUrl, PERIODIC_CHK_VIDEO_URL_MS);
	  }

	periodicChkVideoUrl();

	}) ();

	// -----------------------------------------------------------------------------

	}) ();