NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name YouTube Subscriptions Bookmark-Friendly Video Titles
// @namespace https://github.com/picodexter/youtube-subscriptions-bookmark-friendly-video-titles
// @description Prepends the channel name and the video duration to the video titles in YouTube's subscription feed.
// @version 1.0.7
// @author picodexter (https://picodexter.io/)
// @copyright 2017+, picodexter (https://picodexter.io/)
// @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
// @grant none
// @homepageURL https://github.com/picodexter/youtube-subscriptions-bookmark-friendly-video-titles
// @match https://www.youtube.com/feed/subscriptions
// @match https://www.youtube.com/feed/subscriptions?*
// @supportURL https://github.com/picodexter/youtube-subscriptions-bookmark-friendly-video-titles/issues
// ==/UserScript==
(function () {
'use strict';
const VideoTitleRewriter = function () {
const debug = false;
let usingGridView = null;
let usingWebComponents = null;
const selectors = {
'channelNameElement': {
'webComponents0': '.yt-lockup-byline > a',
'webComponents1': '#metadata #byline-container #channel-name #text.ytd-channel-name > a',
},
'feedContainerElement': {
'webComponents0': '#browse-items-primary',
'webComponents1': '.ytd-browse > #primary > ytd-section-list-renderer > #contents',
},
'feedItemElements': {
'webComponents0_gridView0': '.feed-item-container .feed-item-dismissable',
'webComponents0_gridView1': '.shelf-content .yt-shelf-grid-item',
'webComponents1_gridView0': '#contents.ytd-item-section-renderer',
'webComponents1_gridView1': '#items > .ytd-grid-renderer',
},
'videoDurationElement': {
'webComponents0': '.yt-thumb .video-time',
'webComponents1': '#thumbnail #overlays span.ytd-thumbnail-overlay-time-status-renderer',
},
'videoTitleElement': {
'webComponents0': '.yt-lockup-title > a',
'webComponents1': '#meta h3 a#video-title',
},
};
/**
* Run rewriter.
*/
this.run = function () {
debugMessage('Running video title rewriter.');
debugMessage('STATUS: Using Web Components:', isUsingWebComponents());
debugMessage('STATUS: Grid view detected:', isGridView());
const feedContainer = getFeedContainerElement();
if (!feedContainer) {
return;
}
debugMessage('Feed container: ', feedContainer);
const feedItemElements = getFeedItemElements(feedContainer);
for (let i = 0; i < feedItemElements.length; i++) {
const currentFeedItemElement = feedItemElements[i];
if ('1' === currentFeedItemElement.dataset.ytsbtpProcessed) {
continue;
}
debugMessage('Found unprocessed list entry.', currentFeedItemElement);
/*
* Video duration
*/
const videoDurationElement = getVideoDurationElement(currentFeedItemElement);
if (!videoDurationElement) {
debugMessage('SKIP: Could not get video duration.');
continue;
}
let videoDuration = videoDurationElement.innerHTML.trim();
if (videoDuration.indexOf('<') > -1) {
videoDuration = videoDuration.substr(0, videoDuration.indexOf('<'));
}
debugMessage('Video duration: ' + videoDuration);
/*
* Channel name
*/
const channelNameElement = getChannelNameElement(currentFeedItemElement);
if (!channelNameElement) {
debugMessage('SKIP: Could not get channel name.');
continue;
}
let channelName = channelNameElement.innerHTML;
debugMessage('Channel name: ' + channelName);
/*
* Video title
*/
const videoTitleElement = getVideoTitleElement(currentFeedItemElement);
if (!videoTitleElement) {
debugMessage('SKIP: Could not get video title.');
continue;
}
let videoTitle = videoTitleElement.innerText.trim();
debugMessage('Video title: ' + videoTitle);
let separator = (videoDuration === '' ? ' | ' : ' [' + formatDuration(videoDuration) + '] ');
let newTitle = channelName + separator + videoTitle;
newTitle = newTitle.trim();
videoTitleElement.innerHTML = newTitle;
currentFeedItemElement.dataset.ytsbtpProcessed = '1';
debugMessage('List entry successfully processed.');
}
debugMessage('End of running video title rewriter.');
};
/**
* Register observer.
*
* Observes DOM changes in case content gets added via AJAX ("load more"). Triggers run().
*/
this.registerObserver = function () {
const feedContainer = getFeedContainerElement();
if (!feedContainer) {
debugMessage('No feed container found for binding MutationObserver event.');
return;
}
const observer = new MutationObserver(function (mutationRecords) {
let runRewriter = false;
for (let i = 0; i < mutationRecords.length; i++) {
const mutationRecord = mutationRecords[i];
if ((mutationRecord.type !== 'childList') || (mutationRecord.addedNodes.length === 0)) {
continue;
}
const addedNodes = mutationRecord.addedNodes;
let videoDurationElementFound = false;
for (let j = 0; j < addedNodes.length; j++) {
const addedNode = addedNodes[j];
if (addedNode.matches(videoDurationElementSelector)
|| (addedNode.querySelector(videoDurationElementSelector) !== null)
) {
videoDurationElementFound = true;
break;
}
}
if (!videoDurationElementFound) {
continue;
}
runRewriter = true;
break;
}
if (runRewriter) {
rewriteCounts++;
debugMessage('Running rewriter, call #', rewriteCounts);
rewriter.run();
}
});
observer.observe(
feedContainer,
{
childList: true,
attributes: true,
characterData: true,
subtree: true
}
);
debugMessage('Mutation observer bound to feed container.');
};
/**
* Output debug message.
*
* Only works if debug mode is enabled.
*
* @param {...*} arguments
*/
const debugMessage = function () {
if (debug) {
console.info(...arguments);
}
};
/**
* Format duration.
*
* Prepends leading zeroes to single-digit units.
*
* @param {string} duration
*
* @returns {string}
*/
const formatDuration = function (duration) {
let t = duration.split(/:/);
let r = [];
for (let i = 0; i < t.length; i++) {
r.push(t[i].length < 2 ? '0' + t[i] : t[i]);
}
return r.join(':');
};
/**
* Get element containing the channel name.
*
* @param {Element} feedItemElement
*
* @returns {Element}
*/
const getChannelNameElement = function (feedItemElement) {
return getElementByName('channelNameElement', feedItemElement);
};
/**
* Get element by name.
*
* @param {string} elementName
* @param {ParentNode} parentNode
*
* @returns {Element}
*/
const getElementByName = function (elementName, parentNode) {
return parentNode.querySelector(getElementSelector(elementName));
};
/**
* Get elements by name.
*
* @param {string} elementName
* @param {ParentNode} parentNode
*
* @returns {NodeList}
*/
const getElementsByName = function (elementName, parentNode) {
return parentNode.querySelectorAll(getElementSelector(elementName));
};
/**
* Get element selector.
*
* @param {string} elementName
*
* @returns {string|null}
*/
const getElementSelector = function (elementName) {
const modeKeyWebComponents = 'webComponents' + (isUsingWebComponents() ? '1' : '0');
const modeKeyFull = modeKeyWebComponents + '_gridView' + (isGridView() ? '1' : '0');
let selector = null;
if (typeof selectors[elementName] !== 'undefined') {
let modeKey = (typeof selectors[elementName][modeKeyFull] !== 'undefined'
? modeKeyFull
: modeKeyWebComponents);
selector = (typeof selectors[elementName][modeKey] !== 'undefined'
? selectors[elementName][modeKey]
: null);
}
debugMessage('Selector for element name ' + elementName + ':', selector);
return selector;
};
/**
* Get the feed container element.
*
* @returns {Element}
*/
const getFeedContainerElement = function () {
return getElementByName('feedContainerElement', document);
};
/**
* Get feed item elements.
*
* @param {Element} feedContainer
*
* @returns {Element[]|NodeList}
*/
const getFeedItemElements = function (feedContainer) {
return getElementsByName('feedItemElements', feedContainer);
};
/**
* Get element containing the video duration.
*
* @param {Element} feedItemElement
*
* @returns {Element}
*/
const getVideoDurationElement = function (feedItemElement) {
return getElementByName('videoDurationElement', feedItemElement);
};
/**
* Get element containing the video title.
*
* @param {Element} feedItemElement
*
* @returns {Element}
*/
const getVideoTitleElement = function (feedItemElement) {
return getElementByName('videoTitleElement', feedItemElement);
};
/**
* Check if website is using grid view.
*
* @returns {boolean}
*/
const isGridView = function () {
if (null === usingGridView) {
if (isUsingWebComponents()) {
usingGridView = (null !== document.querySelector('#items.ytd-grid-renderer'));
} else {
usingGridView = (null !== document.querySelector('.yt-shelf-grid-item'));
}
}
return usingGridView;
};
/**
* Check if website is using Web Components.
*
* @returns {boolean}
*/
const isUsingWebComponents = function () {
if (null === usingWebComponents) {
usingWebComponents = (null !== document.querySelector('template'));
}
return usingWebComponents;
};
/**
* Rewrite counts.
*/
let rewriteCounts = 0;
/**
* Selector cache.
*/
const videoDurationElementSelector = getElementSelector('videoDurationElement');
};
const rewriter = new VideoTitleRewriter();
rewriter.registerObserver();
rewriter.run();
})();