Filter out private videos, sort videos based on ratings, copy video links (for yt-dlp), and copy video file direct link (for IDM).
// ==UserScript==
// @name Thisvid Utility
// @namespace https://github.com/petrelpine
// @version 1.1
// @description Filter out private videos, sort videos based on ratings, copy video links (for yt-dlp), and copy video file direct link (for IDM).
// @icon data:image/gif;base64,R0lGODlhEAAQAPYAAAAAAAABAAEAAAEBAQIBAAICAgMDAwIBBgMDBQQEBAUFBQcHBQYGBggICAwMCg4ODA4ODhAPDRAODxERERISEhUTFBUVFRYWFhcXFxkYFhsZGh8fHyMjIzQ0NDc3NzsyNTo6Oj4+PkY+PEhDP0hEQUxHQ01NTVBLSFxSUF1TUWFWUmlcVmheXGNjY21lYm9lY29raG9ubHBmZHFnZXNoYnFsaXJua3pwbn1zcXpyf396dnp6en97eHx8fH+BgIF9eoZ9eP8AAIKCgoyIhY2JhoqKipGFhZaKipCQkJSUlJeXl5iYmKSkpKmpqbCwsLq6usDAwMXFxdXV1eLi4gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAlkAFQAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAEAAQAAAH/oBUVFRUVFRUVFRUVFRUVFRUVFRUVE0eCw8AETZUVFRUVFRUVBgYSkdGREQ2EztUVFRUVBM/OkBBOTg4OD8ZPlRUVD0bNTc0QUEzMy4uMBxUVFQTLSwqK0FBQSkoKCcAS1RQCR4lIyNBQUFBHyIkACBQQgAAEhoVQUFBQUEOAAgaQpAkAAAAQIIgQYIECRIEAAAMSJAAaAAAQIIgQYIECQIAQAIMSKJQAAAAAIAgQYIEQQAgAQATUahQSAAgQYIgQYIAAJAgwYQmVKgoeQAAQIIgQQAAAAAAwAQqVKhMgYABQIIgAAAAANAAABMqVKhQkYIBQAIAABIkANBgCRUqFVSoUKFC5UkIAAASJChChQoVKlQCAQAh+QQJLAFUACwAAAAAEAAQAIYAAAAAAQABAAABAQECAQACAgIDAwMCAQYDAwUEBAQFBQUHBwUGBgYICAgMDAoODgwODg4QDw0QDg8RERESEhIVExQVFRUWFhYXFxcZGBYbGRofHx8jIyM0NDQ3Nzc7MjU6Ojo+Pj5GPjxIQz9IREFMR0NNTU1QS0hcUlBdU1FhVlJpXFZoXlxjY2NtZWJvZWNva2hvbmxwZmRxZ2VzaGJxbGlybmt6cG59c3F6cn9/enZ6enp/e3h8fHx/gYCBfXqGfXiCgoKMiIWNiYaKioqRhYWWioqQkJCUlJSXl5eYmJikpKSpqamwsLC6urrAwMDFxcXV1dXi4uL///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/oBUVFRUVFRUVFRUVFRUVFRUVFRUVEweCw8AETZUVFRUVFRUVBgYSUZFQ0M2EztUVFRUVBM/OkBTOTg4OD8ZPlRUVD0bNTc0U1MyMi4uMBxUVFQTLSwqK1NTUygoKCcASlRPCR4lIyNTU1NTHyIkACBPQQAAEhoVU1NTU1MOAAgaQY4kAAAAQIIpU6ZMmTIFAAAMR44kSAAAAIApU6ZMmQIAAAAMR6BMAAAAAIApU6ZMQQAgAQATUKhMSAAgQYIpU6YAAJAgwQQmVKgkeQAAQIIpUwAAAAAAwAQqVKhIkYABQIIpAAAAAOAAwBIqVKhQiYIBQAIAABAAAOBACRUqFVSoUKFCxUkIAAASJCBChQoVKlQCAQA7
// @author PetrelPine
// @license GNU General Public License v3.0
// @match http*://thisvid.com/*
// ==/UserScript==
/*
* How to batch download videos:
* 1. At the video listing page, click "COPY ALL LINKS" button to copy video links of that category.
* 2. Paste these links into a file named links.txt.
* 3. Run yt-dlp -g -a "links.txt" > "links_out.txt".
* 4. All video file direct links are stored in links_out.txt. Copy these links.
* 5. Open IDM and select "add batch download from clipboard".
*/
(() => {
'use strict';
const DEBUG = true;
/*
* A URL is considered a video-page URL when it contains "/videos/".
*
* The "i" makes the match case-insensitive.
*/
const VIDEO_URL_RE = /\/videos\//i;
/*
* Styling for the "copy links" button.
*/
const BTN_CSS =
'display:inline-block !important;' +
'margin:10px 8px 10px 0;' +
'padding:6px 10px;' +
'font-family:inherit;' +
'font-size:13px;' +
'line-height:1.4;' +
'cursor:pointer;' +
'border:1px solid rgba(128,128,128,.5);' +
'border-radius:3px;' +
'background:transparent;' +
'color:inherit;';
/* ================================================================
GENERAL HELPERS
================================================================ */
// Print diagnostic information only when DEBUG is enabled.
const log = (...args) => {
if (DEBUG) {
console.log('[dl]', ...args);
}
};
// Promise-based delay.
const wait = (ms) =>
new Promise(resolve => setTimeout(resolve, ms));
/*
* Repeatedly call fn() until it returns a truthy value or the timeout
* expires.
*
* This is useful because the website may construct its video lists
* and player asynchronously.
*/
const waitFor = async (
fn,
{ timeout = 10000, interval = 100 } = {}
) => {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
try {
const value = fn();
if (value) {
return value;
}
} catch {
// Ignore temporary DOM errors while the page is loading.
}
await wait(interval);
}
return null;
};
/*
* Add ✓ or ✗ to the document title.
*
* If the title already starts with one of our markers, remove the old
* marker first so they do not accumulate.
*/
const setFlag = (mark) => {
document.title =
`[${mark}] ${document.title.replace(/^\[[✓✗]\]\s*/, '')}`;
};
/*
* Copy text to the clipboard.
*
* Preferred method:
* navigator.clipboard.writeText()
*
* Fallback:
* document.execCommand('copy')
*
* The fallback is useful on pages where the modern Clipboard API
* cannot be used.
*/
const copyText = async (text) => {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.cssText =
'position:fixed;' +
'top:-1000px;' +
'left:-1000px;' +
'opacity:0;';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const ok = document.execCommand('copy');
textarea.remove();
if (!ok) {
throw new Error('execCommand copy rejected');
}
return true;
};
/* ================================================================
SINGLE VIDEO PAGE
================================================================ */
/*
* The video player stores its resolved video URLs in flashvars.
*
* Tampermonkey's unsafeWindow can sometimes provide access to the
* page's actual JavaScript global object, so check it first.
*/
const getFlashvars = () => {
const scopes = [];
if (typeof unsafeWindow !== 'undefined') {
scopes.push(unsafeWindow);
}
scopes.push(window);
for (const scope of scopes) {
if (
scope &&
scope.flashvars &&
typeof scope.flashvars === 'object'
) {
return scope.flashvars;
}
}
return null;
};
/*
* Extract usable video URLs from flashvars.
*
* The main source is usually video_url.
*
* The website may also expose:
*
* video_alt_url
* video_alt_url2
* ...
* video_alt_url6
*/
const collectSources = (flashvars) => {
const sources = [];
const addSource = (key) => {
const url = flashvars[key];
if (!url || typeof url !== 'string') {
return;
}
/*
* A URL beginning with "function/<number>/" means that the
* player has not finished resolving the URL yet.
*/
if (/^function\/\d+\//.test(url)) {
log('source still encoded:', key);
return;
}
sources.push({
url,
label: flashvars[`${key}_text`] || key,
});
};
addSource('video_url');
addSource('video_alt_url');
for (let i = 2; i <= 6; i++) {
addSource(`video_alt_url${i}`);
}
return sources;
};
/*
* Create the copy button for a single video page.
*/
const createVideoPageButton = (flagContainer, source) => {
const li = document.createElement('li');
const a = document.createElement('a');
const span = document.createElement('span');
li.className = '__dl_item';
a.className = '__dl';
/*
* Keep the real URL as href as well.
*
* The click handler below prevents normal navigation and copies
* the URL instead.
*/
a.href = source.url;
a.title = `Copy ${source.label} link`;
// 1. Update LI to serve as the relative positioning anchor and stretch its content
li.style.setProperty('position', 'relative', 'important');
li.style.setProperty('display', 'flex', 'important');
li.style.setProperty('align-items', 'center', 'important');
li.style.setProperty('justify-content', 'center', 'important');
// li.style.setProperty('height', '55px', 'important');
// 2. Update A to fill the <li> completely without escaping its bounds
a.style.setProperty('display', 'flex', 'important');
a.style.setProperty('align-items', 'center', 'important');
a.style.setProperty('justify-content', 'center', 'important');
a.style.setProperty('width', '100%', 'important');
a.style.setProperty('height', '100%', 'important');
a.style.setProperty('position', 'relative', 'important'); // Reset position back to relative
a.style.setProperty('top', 'auto', 'important');
a.style.setProperty('left', 'auto', 'important');
a.style.setProperty('visibility', 'visible', 'important');
a.style.setProperty('opacity', '1', 'important');
a.style.setProperty('color', '#fff', 'important');
a.style.cursor = 'pointer';
span.style.setProperty('display', 'flex', 'important');
span.style.setProperty('align-items', 'center', 'important');
span.style.setProperty('justify-content', 'center', 'important');
span.style.setProperty('width', '100%', 'important');
span.style.setProperty('height', '100%', 'important');
span.style.setProperty('font-size', '2em', 'important');
span.style.setProperty('line-height', '1', 'important');
span.style.setProperty('color', '#fff', 'important');
span.textContent = '⧉';
/*
* Temporarily show ✓ or ✗ after copying.
*/
let flashTimer = null;
const flash = (mark) => {
clearTimeout(flashTimer);
span.textContent = mark;
flashTimer = setTimeout(() => {
span.textContent = '⧉';
}, 3000);
};
a.appendChild(span);
li.appendChild(a);
flagContainer.appendChild(li);
// Sync the added button's height with other list elements
function syncHeight() {
const siblingLi = li.previousElementSibling || li.nextElementSibling;
if (siblingLi) {
const height = siblingLi.getBoundingClientRect().height;
if (height > 0) {
li.style.setProperty('height', `${height}px`, 'important');
}
}
}
syncHeight();
// window.addEventListener('resize', syncHeight);
a.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
try {
await copyText(source.url);
flash('✓');
setFlag('✓');
log('copied:', source.label, source.url);
} catch (error) {
log('copy failed:', error);
flash('✗');
setFlag('✗');
/*
* Last-resort fallback if clipboard access fails.
*/
window.prompt(
`Copy this ${source.label} link:`,
source.url
);
}
});
return li;
};
/*
* Make sure the single-video copy button exists.
*
* This function is safe to call repeatedly.
*/
const ensureVideoPageButton = () => {
const flashvars = getFlashvars();
if (!flashvars) {
log('video page: flashvars not ready yet');
return false;
}
const sources = collectSources(flashvars);
if (!sources.length) {
log('video page: no usable source URLs yet');
return false;
}
const flagContainer =
document.querySelector('#flagging_container');
if (!flagContainer) {
log('video page: #flagging_container not ready yet');
return false;
}
/*
* Only add our button if the current toolbar does not already
* contain it.
*/
let button = flagContainer.querySelector('.__dl_item');
if (!button) {
button = createVideoPageButton(
flagContainer,
sources[0]
);
log('video page: button added');
}
return true;
};
/*
* Watch the video page for player/toolbar changes.
*
* Some pages build the player after the initial page load.
* Some players also rebuild their toolbar later.
*
* Watching document.body means that even if the entire
* #flagging_container is replaced, we can detect the replacement.
*/
const watchVideoPage = () => {
let retryTimer = null;
/*
* Schedule a small delayed retry.
*
* Multiple DOM mutations may happen in quick succession, so avoid
* starting hundreds of simultaneous retries.
*/
const tryAgainSoon = () => {
if (retryTimer !== null) {
return;
}
retryTimer = setTimeout(() => {
retryTimer = null;
ensureVideoPageButton();
}, 100);
};
/*
* Try immediately.
*/
ensureVideoPageButton();
/*
* Also retry periodically during initial player construction.
*
* 100 attempts × 100 ms = approximately 10 seconds.
*/
let attempts = 0;
const maxAttempts = 100;
const retryUntilReady = () => {
if (
ensureVideoPageButton() ||
attempts >= maxAttempts
) {
return;
}
attempts++;
setTimeout(retryUntilReady, 100);
};
retryUntilReady();
/*
* Watch for later DOM changes.
*/
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (
mutation.type === 'childList' &&
mutation.addedNodes.length
) {
tryAgainSoon();
break;
}
}
});
if (document.body) {
observer.observe(document.body, {
childList: true,
subtree: true,
});
}
};
/* ================================================================
COLLECT PAGE LINKS
================================================================ */
/*
* Collect all HTTP(S) links inside the container.
*/
const collectPageLinks = (container) => {
const seen = new Set();
for (const link of container.querySelectorAll('a[href]')) {
const href = link.href;
if (!/^https?:/i.test(href)) {
continue;
}
seen.add(href.split('#')[0]);
}
const allLinks = [...seen];
const videoLinks =
allLinks.filter(url => VIDEO_URL_RE.test(url));
if (!videoLinks.length) {
log(
'no links matched VIDEO_URL_RE; using all links'
);
}
return videoLinks.length
? videoLinks
: allLinks;
};
/* ================================================================
COPY PAGE LINKS BUTTONS
================================================================ */
/*
* Add a copy button for each logical category.
*
* If a page contains:
*
* Favourite videos
* Public videos
* Private videos
*
* each section receives its own copy button.
*/
const addListingButtons = () => {
const groups = getVideoListGroups();
log(
`buttons: found ${groups.size} video category(s)`
);
for (const group of groups.values()) {
const primary = group.primary;
if (!primary) {
continue;
}
/*
* Only add one button for the entire logical category.
*
* The additional .thumbs-items containers belonging to
* the same category will not receive their own buttons.
*/
if (
primary.querySelector(
'.__dl_bulk[data-list-category]'
)
) {
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.className = '__dl_bulk';
button.dataset.listCategory = group.key;
button.textContent = '⧉ Copy all links';
button.style.cssText = BTN_CSS;
const originalLabel = button.textContent;
let flashTimer = null;
const flash = (text, delay = 3000) => {
clearTimeout(flashTimer);
button.textContent = text;
flashTimer = setTimeout(() => {
button.textContent = originalLabel;
}, delay);
};
button.addEventListener(
'click',
async (event) => {
event.preventDefault();
event.stopPropagation();
const links = [];
/*
* Collect videos from every .thumbs-items container
* belonging to this category.
*/
for (const thumbs of group.containers) {
links.push(...collectPageLinks(thumbs));
}
if (!links.length) {
flash('✗ Nothing found');
return;
}
try {
await copyText(links.join('\n'));
flash(`✓ Copied ${links.length}`);
log('copied page links:', links);
}
catch (error) {
log('copy failed:', error);
flash('✗ Copy failed');
window.prompt(
'Copy these links:',
links.join('\n')
);
}
}
);
/*
* Put the button immediately before the primary
* .thumbs-items container for this category.
*/
primary.parentElement?.insertBefore(
button,
primary
);
log(`buttons: added button for ${group.key}`);
}
};
/* ================================================================
VIDEO LIST CATEGORY / GROUP HELPERS
================================================================ */
/*
* A category can appear in the DOM in two forms:
*
* list_videos_public_videos_items
*
* or:
*
* list_videos_favourite_videos
*
* We normalize both to:
*
* list_videos_public_videos
* list_videos_favourite_videos
*
* so they can be used as the same category key.
*/
const normalizeVideoCategoryId = (id) => {
if (!id || !id.startsWith('list_videos_')) {
return null;
}
return id.replace(/_items$/, '');
};
/*
* Return all elements that can act as category markers.
*
* We intentionally look for every list_videos_* element rather than
* only list_videos_*_items because, as shown in your screenshot,
* Favourite has:
*
* #list_videos_favourite_videos
* └── #list_videos_favourite_videos_items
*
* while Public/Private may use the *_items element directly.
*/
const getVideoCategoryMarkers = () => {
return [
...document.querySelectorAll(
'[id^="list_videos_"]'
)
];
};
/*
* Determine which video category a particular .thumbs-items belongs to.
*/
const getVideoCategoryForThumbs = (
thumbs,
categoryMarkers
) => {
/*
* ------------------------------------------------------------
* Case 1:
* The .thumbs-items itself has an ID.
*
* Example:
*
* <div
* class="thumbs-items"
* id="list_videos_public_videos_items"
* >
* ------------------------------------------------------------
*/
if (
thumbs.id &&
thumbs.id.startsWith('list_videos_')
) {
return normalizeVideoCategoryId(thumbs.id);
}
/*
* ------------------------------------------------------------
* Case 2:
* The .thumbs-items is inside a category wrapper.
*
* Example from your screenshot:
*
* <div id="list_videos_favourite_videos">
* ...
* <div class="thumbs-items">
*
* ------------------------------------------------------------
*/
const ancestor = thumbs.closest(
'[id^="list_videos_"]'
);
if (
ancestor &&
ancestor.id !== thumbs.id
) {
return normalizeVideoCategoryId(
ancestor.id
);
}
/*
* ------------------------------------------------------------
* Case 3:
* There is no category wrapper.
*
* Public/Private appear to have this structure:
*
* #list_videos_public_videos_items
* .container
* .thumbs-items
* .container
* .thumbs-items
*
* The later .thumbs-items do not have IDs.
*
* Therefore, find the closest previous list_videos_* marker
* in document order.
* ------------------------------------------------------------
*/
let latestMarker = null;
for (const marker of categoryMarkers) {
/*
* marker comes before thumbs in the document.
*
* compareDocumentPosition() with
* DOCUMENT_POSITION_FOLLOWING means:
*
* marker -> thumbs
*
* in document order.
*/
if (
marker === thumbs ||
(
marker.compareDocumentPosition(thumbs) &
Node.DOCUMENT_POSITION_FOLLOWING
)
) {
latestMarker = marker;
}
}
if (!latestMarker) {
return null;
}
return normalizeVideoCategoryId(
latestMarker.id
);
};
/*
* Build the complete groups of .thumbs-items.
*
* The result looks conceptually like:
*
* Map {
* "list_videos_public_videos" => {
* key: "...",
* containers: [
* thumbs-items,
* thumbs-items,
* thumbs-items
* ],
* primary: thumbs-items
* },
*
* "list_videos_private_videos" => {
* ...
* },
*
* "list_videos_favourite_videos" => {
* ...
* }
* }
*/
const getVideoListGroups = () => {
const groups = new Map();
const categoryMarkers =
getVideoCategoryMarkers();
const thumbsItems = [
...document.querySelectorAll(
'.thumbs-items'
)
];
for (const thumbs of thumbsItems) {
const category =
getVideoCategoryForThumbs(
thumbs,
categoryMarkers
);
if (!category) {
log(
'group: could not determine category for',
thumbs
);
continue;
}
if (!groups.has(category)) {
groups.set(category, {
key: category,
containers: [],
primary: null,
});
}
const group = groups.get(category);
group.containers.push(thumbs);
/*
* Prefer the explicitly named *_items element as the primary
* container.
*
* For example:
*
* #list_videos_public_videos_items
*
* If there is no explicitly named one, simply use the first
* .thumbs-items we encountered.
*/
if (thumbs.id === `${category}_items`) {
group.primary = thumbs;
}
}
/*
* Some groups might not have an explicitly named primary container.
*/
for (const group of groups.values()) {
if (!group.primary) {
group.primary = group.containers[0];
}
}
return groups;
};
/*
* Find the top-level video card inside a particular .thumbs-items.
*
* We start from an <a> and walk upward until its parent is the
* .thumbs-items container.
*/
const getVideoCard = (element, thumbsContainer) => {
let current = element;
while (
current &&
current.parentElement
) {
if (
current.parentElement === thumbsContainer
) {
return current;
}
current = current.parentElement;
}
return null;
};
/* ================================================================
FILTER PRIVATE VIDEOS
================================================================ */
const filterPrivateVideos = () => {
const groups = getVideoListGroups();
log(`filter: found ${groups.size} video category(s)`);
/*
* Process every category independently.
*/
for (const group of groups.values()) {
let removed = 0;
/*
* A category may contain several .thumbs-items containers.
*
* Search ALL of them.
*/
for (const thumbs of group.containers) {
const privateIcons = [
...thumbs.querySelectorAll(
'.icon-private'
)
];
for (const icon of privateIcons) {
const videoCard =
getVideoCard(
icon,
thumbs
);
if (!videoCard) {
log(
'filter: could not find card for',
icon
);
continue;
}
/*
* Make sure the card really belongs directly to
* this .thumbs-items.
*/
if (videoCard.parentElement !== thumbs) {
continue;
}
videoCard.remove();
removed++;
}
}
log(`filter: ${group.key}: removed ${removed} private video(s)`);
}
};
/* ================================================================
SORT VIDEOS
================================================================ */
const sortVideos = () => {
const groups = getVideoListGroups();
log(`sort: found ${groups.size} video category(s)`);
/*
* Sort each category independently.
*/
for (const group of groups.values()) {
const videos = [];
/*
* Collect videos from every .thumbs-items belonging to this category.
*/
for (const thumbs of group.containers) {
const percentages = [
...thumbs.querySelectorAll(
'span.percent'
)
];
for (const percentage of percentages) {
const videoCard =
getVideoCard(
percentage,
thumbs
);
if (!videoCard) {
continue;
}
/*
* Extract something such as "85%" into 85
*/
const value = parseInt(
percentage.textContent.trim(),
10
);
videos.push({
element: videoCard,
value: Number.isNaN(value)
? -Infinity
: value,
/*
* Preserve the original order for equal percentages.
*/
originalIndex: videos.length,
});
}
}
if (videos.length < 2) {
continue;
}
/*
* Highest percentage first.
*
* Equal percentages retain their original order.
*/
videos.sort((a, b) => {
if (b.value !== a.value) {
return b.value - a.value;
}
return (
a.originalIndex -
b.originalIndex
);
});
/*
* ------------------------------------------------------------
* The category may contain multiple .thumbs-items elements.
*
* We want the sorted videos to become one continuous list,
* rather than leaving videos scattered across multiple
* .thumbs-items containers.
*
* Therefore, put all sorted video cards into the category's
* primary .thumbs-items container.
* ------------------------------------------------------------
*/
const primary = group.primary;
if (!primary) {
continue;
}
/*
* Move each video card into the primary container.
*
* appendChild() moves the existing element; it does not clone it.
*/
for (const video of videos) {
primary.appendChild(
video.element
);
}
/*
* The other .thumbs-items containers are now empty of video
* cards. We leave the containers themselves in place because
* they may be used by the site's pagination/layout system.
*/
log(
`sort: ${group.key}: sorted ${videos.length} video(s)`
);
}
};
/* ================================================================
STARTUP
================================================================ */
const start = () => {
/*
* Wait briefly because the site may still be constructing its
* video-list containers.
*/
setTimeout(() => {
try {
filterPrivateVideos();
} catch (error) {
log(
'filterPrivateVideos failed:',
error
);
}
}, 100);
/*
* Run sorting after filtering.
*
* This is intentional: private videos should be removed before
* the remaining videos are sorted.
*/
setTimeout(() => {
try {
sortVideos();
} catch (error) {
log(
'sortVideos failed:',
error
);
}
}, 200);
/*
* Only install the single-video MutationObserver on actual
* video pages.
*/
if (VIDEO_URL_RE.test(location.href)) {
watchVideoPage();
}
/*
* Add copy buttons to all listing containers.
*/
addListingButtons().catch(error => {
log(
'addListingButtons threw:',
error
);
});
};
/*
* Tampermonkey can inject the script after the load event has already
* fired, so support both situations.
*/
if (document.readyState === 'complete') {
start();
} else {
window.addEventListener(
'load',
start,
{ once: true }
);
}
})();