Skip to content

Instantly share code, notes, and snippets.

@TheJzoli
Last active Jun 11, 2022
Embed
What would you like to do?
Userscripts for sb.ltn.fi

Userscripts for sb.ltn.fi (by TheJzoli)

  • sb.ltn.fi.clickableellipsisnavigation.user.js
    Makes it so clicking the ellipsis in the page navigation will show a prompt that asks what page you want to navigate to.
    Install
  • sb.ltn.fi.copyuserid.user.js
    Adds a button on the userID page to copy that userID
    Install
  • sb.ltn.fi.copyvideopagelink.user.js
    Makes the "copy video ID" () button copy the video's SBB page's link to clipboard instead of the video ID.
    Install
  • sb.ltn.fi.forceupdate.user.js
    Fetches the segments and updated info for the video, so that you don't have to wait for the video page to update.
    Note: You can also press the 'R' key on your keyboard to force a refresh
    Install
  • sb.ltn.fi.hidecolumns.user.js
    Hide any column you want on sb.ltn.fi
    Install
  • sb.ltn.fi.pagenavigationabovetable.user.js
    Duplicates the page navigation element and puts it above the table so you don't have to scroll down to change the page.
    Note: To have the clickable ellipsis navigation script work for this page navigation element as well, this script needs to come before it in the Installed Userscripts section on Tampermonkey. Sort by "#" and drag this script above the "Clickable ellipsis navigation for sb.ltn.fi" script.
    Install
  • sb.ltn.fi.videotitles.user.js
    Replaces the video ID with the video title in the 'Video ID' column
    Install
// ==UserScript==
// @name Clickable ellipsis navigation for sb.ltn.fi
// @namespace sb.ltn.fi.clickable.ellipsis.navigation
// @version 1.0.2
// @description Makes it so clicking the ellipsis in the page navigation will show a prompt that asks what page you want to navigate to.
// @author TheJzoli
// @match https://sb.ltn.fi/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
[...document.getElementsByClassName('pagination')].forEach(paginationEl => {
const pageBtns = [...paginationEl.querySelectorAll('li a')];
const pageBtnTexts = pageBtns.map(item => item.textContent.trim());
const ellipsisBtns = pageBtns.filter(item => item.textContent.trim() === '...');
ellipsisBtns.forEach(btn => {
btn.style.cursor = 'pointer';
btn.onclick = () => {
let currentPage = 1;
const searchParams = new URLSearchParams(location.search);
let possibleCurrentPage = searchParams.get('page');
if (possibleCurrentPage) {
currentPage = possibleCurrentPage;
}
let maxPage = pageBtnTexts[pageBtnTexts.length - 1];
if (!parseInt(maxPage)) {
maxPage = pageBtnTexts[pageBtnTexts.length - 2];
}
let page = prompt(`Jump to page: (1-${maxPage})`, currentPage);
if (parseInt(page)) {
searchParams.set('page', Math.min(maxPage, Math.max(1, page)));
document.location = location.origin + location.pathname + '?' + searchParams.toString();
}
}
});
});
})();
// ==UserScript==
// @name Copy UserID button for sb.ltn.fi
// @namespace sb.ltn.fi.copy.userid
// @version 1.0.3
// @description Adds a button on the userID page to copy that userID
// @author TheJzoli
// @match https://sb.ltn.fi/userid/*
// @grant GM_setClipboard
// ==/UserScript==
(function() {
'use strict';
const btn = document.createElement('button');
btn.className = 'btn btn-primary';
btn.style.margin = '20px';
btn.innerText = 'Copy userID';
const userID = location.pathname.split('/')[2];
btn.onclick = () => GM_setClipboard(userID);
const element = document.getElementsByClassName('list-group-horizontal')[0];
if (element) {
element.appendChild(btn);
}
})();
// ==UserScript==
// @name Force-update video page for sb.ltn.fi
// @namespace sb.ltn.fi.force.update
// @version 1.1.8
// @description Fetches the segments and updated info for the video, so that you don't have to wait for the video page to update.
// @author TheJzoli
// @match https://sb.ltn.fi/video/*
// @match https://sb.ltn.fi/uuid/*
// @icon https://sb.ltn.fi/static/browser/logo.png
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @connect sponsor.ajay.app
// @updateURL https://gist.github.com/TheJzoli/8a4cd979d433b7359cdf61c238bc0181/raw/sb.ltn.fi.forceupdate.user.js
// @downloadURL https://gist.github.com/TheJzoli/8a4cd979d433b7359cdf61c238bc0181/raw/sb.ltn.fi.forceupdate.user.js
// ==/UserScript==
(function() {
'use strict';
const SBLTNFI_TO_API_PARAMS = {views_min: 'minViews', views_max: 'maxViews', votes_min: 'minVotes', votes_max: 'maxVotes', category: 'category'};
const BASEAPIURL = 'https://sponsor.ajay.app/api';
const ignoreParams = ['shadowhidden', 'uuid', 'username', 'user', 'sort', 'page'];
const exclude = ['input', 'textarea'];
const listNextToVideoEmbed = document.getElementsByClassName('list-group')[document.getElementsByClassName('list-group').length - 1];
// Ways to have user refresh via a button or the R key
addRefreshButton();
document.addEventListener('keyup', (e) => {
if (exclude.indexOf(e.target.tagName.toLowerCase()) === -1 && e.code === 'KeyR') {
const videoID = checkPageVideoID();
if (!videoID) return;
getAndUpdateSegments(videoID);
getAndUpdateLocks(videoID);
}
});
/**
* Gets all the video's segments, then calls the {@link updateTable} function, and updates the submission amount.
* @param {string} videoID ID of the video whose segments we want to fetch
*/
function getAndUpdateSegments(videoID) {
document.body.style.cursor = 'progress';
let segmentsArray = [];
const searchParams = new URLSearchParams(location.search);
const filters = buildFilterParam(searchParams);
let page = 0;
let segmentCount = 0;
const callback = (responseObject) => {
if (responseObject.status !== 200) {
if (responseObject.status === 404) {
window.alert('No segments found for video');
} else {
window.alert(`API returned a status of ${responseObject.status} when getting segments`);
}
document.body.style.cursor = 'default';
return;
}
segmentCount = responseObject.response.segmentCount;
segmentsArray = segmentsArray.concat(responseObject.response.segments);
if (segmentCount > 10 && (page+1)*10 < segmentCount) {
getSegments(videoID, filters, ++page, callback);
} else if (segmentsArray.length === segmentCount) {
// Update table once all segments have been fetched
updateTable(segmentsArray, searchParams);
// Update submission amount
getDivThatStartsWithText('Query results:').textContent = `Query results: ${responseObject.response.segmentCount}`;
// If total submissions is over current query's segment count, don't update it.
if (listNextToVideoEmbed.firstElementChild.textContent.split(':')[1].trim() < responseObject.response.segmentCount) {
listNextToVideoEmbed.firstElementChild.textContent = `Submissions: ${responseObject.response.segmentCount}`;
}
// Update ignored amount
let hiddenAmount = 0;
let ignoredAmount = 0;
segmentsArray.forEach(segment => {
hiddenAmount += segment.hidden;
ignoredAmount += segment.shadowHidden || segment.votes <= -2 ? 1 : 0;
});
listNextToVideoEmbed.children[1].textContent = `Ignored: ${ignoredAmount} `;
listNextToVideoEmbed.children[1].appendChild(createSpan('Segments that are also not sent to users, but don\'t count as ignored', `+ ${hiddenAmount} ❓`));
document.body.style.cursor = 'default';
// fire event to signal other scripts of new segments
const event = new CustomEvent("newSegments")
document.dispatchEvent(event)
}
};
getSegments(videoID, filters, page, callback); // TODO only call for specific pages based on SBB page
}
/**
* Calls the SponsorBlock API
* @param {string} videoID ID of the video whose segments we want to fetch
* @param {string} filters Filter params e.g. minVotes etc.
* @param {number} page The page number we want from the API. 1 page is 10 segments
* @param {function} callback callback function to call when onload triggers
*/
function getSegments(videoID, filters, page, callback) {
GM_xmlhttpRequest({
method: 'GET',
url: `${BASEAPIURL}/searchSegments?videoID=${videoID+filters}&page=${page}`,
responseType: 'json',
timeout: 10000,
onload: callback,
onerror: () => {
document.body.style.cursor = 'default';
window.alert('An error occurred getting the segments!');
},
ontimeout: () => {
document.body.style.cursor = 'default';
window.alert('Segments request timed out! API didn\'t answer in time.');
}
});
}
/**
* Gets the video's locked categories and updates them.
* @param {string} videoID ID of the video whose locked categories we want
*/
function getAndUpdateLocks(videoID) {
const skipCallback = (responseObject) => {
updateLockedCategories(responseObject, 'Locked skips: ', 2);
};
const muteCallback = (responseObject) => {
updateLockedCategories(responseObject, 'Locked mutes: ', 3);
};
const fullCallback = (responseObject) => {
updateLockedCategories(responseObject, 'Locked full: ', 4);
};
getLocks(videoID, 'skip', skipCallback);
getLocks(videoID, 'mute', muteCallback);
getLocks(videoID, 'full', fullCallback);
}
/**
* Calls the SponsorBlock API to get the locked categories with the specified actionType for the specified video.
* @param {string} videoID ID of the video whose locked categories we want
* @param {string} actionType Action type of the locked categories
* @param {function} callback callback function to call when the request loads
*/
function getLocks(videoID, actionType, callback) {
GM_xmlhttpRequest({
method: 'GET',
url: `${BASEAPIURL}/lockCategories?videoID=${videoID}&actionTypes=["${actionType}"]`,
responseType: 'json',
timeout: 10000,
onload: callback,
onerror: () => {
window.alert(`An error occurred getting the locked categories with action type '${actionType}'!`);
},
ontimeout: () => {
window.alert(`Locked categories (with action type '${actionType}') request timed out! API didn\'t answer in time.`);
}
});
}
/**
* Updates one of the actionTypes locked categories next to the embed player.
* @param {Object} responseObject onload response from the GM_xmlhttpRequest
* @param {string} startingString starting part of the locked categories string
* @param {number} index index of the actionType in the list next to the embed player
*/
function updateLockedCategories(responseObject, startingString, index) {
if (responseObject.status !== 200 && responseObject.status !== 404) {
let actionType = startingString.slice(7, -3);
if (actionType.length === 3) actionType += 'l';
window.alert(`API returned a status of ${responseObject.status} when getting locked categories with action type ${actionType}`);
}
let lockedString = startingString;
const lockedStringStartingLength = lockedString.length;
responseObject.response?.categories?.forEach((category, i) => {
if (i !== 0) {
lockedString += ', ';
}
lockedString += category;
});
lockedString += lockedString.length === lockedStringStartingLength ? '—' : '';
listNextToVideoEmbed.children[index].textContent = lockedString;
}
/**
* Updates the table with the new data from the provided array
* @param {Object[]} segmentsArray Array of the segments fetched from the API
* @param {URLSearchParams} searchParams Search parameters of the current page
*/
function updateTable(segmentsArray, searchParams) {
[...document.querySelectorAll('table')].forEach(table => {
const headers = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim());
if (headers.indexOf('UUID') === -1) {
return;
}
const rows = [...table.querySelectorAll('tbody tr')];
rows.forEach(row => updateRow(row, headers, segmentsArray)); // Update info for the old segments on the page
// Don't add new segments if on a page other than 1 (TODO remove when page support gets added)
let currentPage = parseInt(searchParams.get('page'));
currentPage = isNaN(currentPage) ? 1 : currentPage;
if (currentPage !== 1) {
return;
}
// Add new segments to the page
const lastUpdateElement = getDivThatStartsWithText('Last update:');
const lastUpdateTime = dateToMillis(lastUpdateElement.textContent.trim().slice(13, 33) + 'UTC');
const newSegments = segmentsArray.filter((segment) => {
if (segment.timeSubmitted > lastUpdateTime) {
let addSegment = true;
if (searchParams.has('shadowhidden')) {
addSegment = addSegment && (segment.shadowHidden.toString() === searchParams.get('shadowhidden'));
}
if (searchParams.has('uuid')) {
addSegment = addSegment && (segment.UUID === searchParams.get('uuid'));
}
if (searchParams.has('user')) {
addSegment = addSegment && (segment.userID === searchParams.get('user'));
}
return addSegment;
}
return false;
});
const tableBody = table.getElementsByTagName('tbody')[0];
let segmentAmountOnPage = rows.length;
newSegments.forEach(segment => {
for (const row of rows) { // Check if the new segment is already on the page from a previous force refresh
if (row.children[headers.indexOf('UUID')].firstElementChild.value === segment.UUID) {
return;
}
}
tableBody.insertAdjacentElement('afterbegin', createRowElement(segment));
if (++segmentAmountOnPage > 25) {
tableBody.removeChild(tableBody.lastElementChild);
segmentAmountOnPage--;
}
});
fixRows(tableBody);
if (newSegments.length > 0 && searchParams.has('username')) { // Notify user that filtering by username isn't supported
window.alert('Filtering by username is not supported due to API limitations. \nNew added segments may be from different users');
}
/* TODO Add support for different sort options and pages
switch (searchParams.get('sort')) {
case 'timesubmitted': // old -> new
break;
case 'starttime':
break;
case 'endtime':
break;
case 'length':
break;
case 'votes':
break;
case 'views':
break;
case 'category':
break;
case 'shadowhidden':
break;
case 'uuid':
break;
case 'username':
break;
case 'actiontype':
break;
case 'hidden':
break;
case 'userid':
break;
case '-userid':
break;
case '-starttime':
break;
case '-endtime':
break;
case '-length':
break;
case '-votes':
break;
case '-views':
break;
case '-category':
break;
case '-shadowhidden':
break;
case '-uuid':
break;
case '-username':
break;
case '-actiontype':
break;
case '-hidden':
break;
case '-timesubmitted': // new -> old
default: // same as -timesubmitted
}
*/
});
}
/**
* Updates the data for the segment in this row
* @param {HTMLTableRowElement} row Row to be updated
* @param {string[]} headers String array of the table's headers
* @param {Object[]} segmentsArray Array of the segments fetched from the API
*/
function updateRow(row, headers, segmentsArray) {
const uuid = row.children[headers.indexOf('UUID')].firstElementChild.value;
const oldSegmentIndex = segmentsArray.findIndex(segment => segment.UUID === uuid); // Find the index for the segment on the page, so we can get its updated info from the API
if (oldSegmentIndex === -1) {
return; // If there is a segment UUID on the page that isn't on the API.
}
// Update Votes column
const votesEl = row.children[headers.indexOf('Votes')];
let underNegTwoVotesSpan;
let lockedSpan;
[...votesEl.children].forEach(el => { // Check for existing icons and save their element
if (el.textContent === '❌') {
underNegTwoVotesSpan = el;
return;
}
if (el.textContent === '🔒') {
lockedSpan = el;
}
});
votesEl.firstChild.textContent = segmentsArray[oldSegmentIndex].votes; // Update votes from API
if (segmentsArray[oldSegmentIndex].votes <= -2) { // Add X symbol if votes under -1 and it already isn't there
if (!underNegTwoVotesSpan) {
votesEl.firstChild.after(createSpan('This segment is not sent to users', '❌'));
}
} else if (underNegTwoVotesSpan) { // Delete X symbol if it is there and votes are over -2
underNegTwoVotesSpan.remove();
}
if (segmentsArray[oldSegmentIndex].locked) { // Add locked symbol if segment is locked and it already isn't there
if (!lockedSpan) {
votesEl.firstChild.after(createSpan('This segment is locked by a VIP', '🔒'));
}
} else if (lockedSpan) { // Delete locked symbol if it is there and the segment isn't locked
lockedSpan.remove();
}
// Update Views column
const viewsEl = row.children[headers.indexOf('Views')];
viewsEl.textContent = segmentsArray[oldSegmentIndex].views;
// Update Category column
const categoryEl = row.children[headers.indexOf('Category')];
const categoryColorEl = categoryEl.getElementsByTagName('span')[0]; // colored categories userscript
const categoryButton = categoryEl.firstElementChild?.tagName.toLowerCase() === 'button' ? categoryEl.firstElementChild : undefined; // VIP tools userscript
if (categoryButton) { // Check for VIP tools userscript button
categoryButton.firstChild.textContent = segmentsArray[oldSegmentIndex].category;
}
if (categoryColorEl) { // Check for colored categories userscript
const newClass = categoryColorEl.classList[1].slice(0, 10) + segmentsArray[oldSegmentIndex].category;
categoryColorEl.classList.replace(categoryColorEl.classList[1], newClass);
categoryColorEl.textContent = segmentsArray[oldSegmentIndex].category;
} else if (!categoryButton) { // If no colored categories, or vip tools userscripts.
categoryEl.firstChild.textContent = segmentsArray[oldSegmentIndex].category;
}
// Update Shadowhidden column
const shadowHiddenEl = row.children[headers.indexOf('Shadowhidden')];
shadowHiddenEl.innerHTML = ''; // empty the element
shadowHiddenEl.appendChild(segmentsArray[oldSegmentIndex].shadowHidden ? createSpan('This segment has been shadowhidden.', '❌') : document.createTextNode('—'));
// Update Hidden column
const hiddenEl = row.children[headers.indexOf('Hidden')];
hiddenEl.innerHTML = ''; // empty the element
hiddenEl.appendChild(segmentsArray[oldSegmentIndex].hidden ? createSpan('This segment is hidden due to video duration change.', '❌') : document.createTextNode('—'));
// Update S/H column if Hide Columns userscript is being used
const shIndex = headers.indexOf('S/H');
if (shIndex !== -1) {
const shEl = row.children[shIndex];
shEl.textContent = segmentsArray[oldSegmentIndex].shadowHidden || segmentsArray[oldSegmentIndex].hidden ? '❌' : '—';
}
}
/**
* Builds a string from the given parameters.
* Transforms the parameters from the way they appear in SBB's url, to what SponsorBlock's API accepts.
* @param {URLSearchParams} searchParams The parameters from SBB's url with which we want to call the API
* @returns {string} Parameters string that should be appended to the API request url
*/
function buildFilterParam(searchParams) {
let paramString = '';
searchParams.forEach((value, key) => {
if (ignoreParams.includes(key)) {
return;
}
paramString += `&${SBLTNFI_TO_API_PARAMS[key]}=${value}`;
});
return paramString;
}
/**
* Returns the div element that starts with the specified text.
* @param {string} text Text we want to search
* @returns {HTMLDivElement}
*/
function getDivThatStartsWithText(text) {
const divElements = Array.from(document.getElementsByTagName('div'));
const result = divElements.filter(element => element.innerHTML.trim().slice(0, text.length) === text);
if (result.length !== 0) {
return result[0];
}
}
/**
* Converts milliseconds to a date.
* @param {number | string} millis Milliseconds to be converted to a date
* @returns {string} The date in a YYYY-MM-DD HH:MM:SS format
*/
function millisToDate(millis) {
return new Date(millis).toISOString().slice(0, -5).replaceAll('T', ' ');
}
/**
* Converts date string to milliseconds.
* @param {string} date Date string. Remember to add "UTC" to the end if date is taken from SBB
* @returns {number}
*/
function dateToMillis(date) {
return new Date(date).getTime();
}
/**
* Converts the time from seconds to hours, minutes, and seconds.
* @param {number | string} timeInSeconds Seconds to be converted
* @returns {string} The time in H:MM:SS.mmmmmm format
*/
function secondsToTime(timeInSeconds) {
const pad = num => { return ('0' + num).slice(-2) },
time = parseFloat(timeInSeconds).toFixed(6),
hours = Math.floor(time / 60 / 60),
minutes = Math.floor(time / 60) % 60,
seconds = Math.floor(time - minutes * 60),
milliseconds = time.split('.')[1];
return hours + ':' + pad(minutes) + ':' + pad(seconds) + (milliseconds === '000000' ? '' : '.' + milliseconds);
}
/**
* Creates a row element and returns it
* @param {Object} segment Segment from the API whose data is used to build the row
* @returns {HTMLTableRowElement}
*/
function createRowElement(segment) {
const row = document.createElement('tr');
const submittedColumn = document.createElement('td');
submittedColumn.textContent = millisToDate(segment.timeSubmitted);
const startColumn = document.createElement('td');
startColumn.textContent = secondsToTime(segment.startTime);
const endColumn = document.createElement('td');
endColumn.textContent = secondsToTime(segment.endTime);
const lengthColumn = document.createElement('td');
lengthColumn.textContent = secondsToTime(segment.endTime - segment.startTime);
const votesColumn = document.createElement('td');
votesColumn.textContent = segment.votes;
if (segment.votes <= -2) {
votesColumn.appendChild(createSpan('This segment is not sent to users', '❌'));
}
if (segment.locked) {
votesColumn.appendChild(createSpan('This segment is locked by a VIP', '🔒'));
}
getAndAddVipStatus(votesColumn, segment.userID);
const viewsColumn = document.createElement('td');
viewsColumn.textContent = segment.views;
const categoryColumn = document.createElement('td');
let hasCategoryColorsScript = false;
[...document.getElementsByTagName('style')].forEach(styleEl => { // Checks if the user has the colored categories script and adds the appropriate classes to the element
[...styleEl.childNodes].forEach(node => {
if (node.data.includes('mruy_sbcc')) {
hasCategoryColorsScript = true;
const spanEl = document.createElement('span');
spanEl.className = `mruy_sbcc mruy_sbcc_${segment.category}`;
spanEl.textContent = segment.category;
categoryColumn.appendChild(spanEl);
}
});
});
if (!hasCategoryColorsScript) categoryColumn.textContent = segment.category;
const shadowhiddenColumn = document.createElement('td');
shadowhiddenColumn.appendChild(segment.shadowHidden ? createSpan('This segment has been shadowhidden.', '❌') : document.createTextNode('—'));
const uuidColumn = createTextAreaColumnElement(segment.UUID, 'UUID');
const usernameColumn = document.createElement('td');
usernameColumn.textContent = '—'; // empty placeholder until username is fetched
getAndAddUsername(usernameColumn, segment.userID);
const actiontypeColumn = document.createElement('td');
actiontypeColumn.appendChild(segment.actionType === 'skip' ? createSpan('Skip', '⏭') : (segment.actionType === 'poi' ? createSpan('Highlight', '✨️') : (segment.actionType === 'full' ? createSpan('Full video', '♾') : (segment.actionType === 'mute' ? createSpan('Mute', '🔇') : document.createTextNode('—')))));
const hiddenColumn = document.createElement('td');
hiddenColumn.appendChild(segment.hidden ? createSpan('This segment is hidden due to video duration change.', '❌') : document.createTextNode('—'));
const userIdColumn = createTextAreaColumnElement(segment.userID, 'UserID');
row.append(submittedColumn, startColumn, endColumn, lengthColumn, votesColumn, viewsColumn, categoryColumn, shadowhiddenColumn, uuidColumn, usernameColumn, actiontypeColumn, hiddenColumn, userIdColumn);
if (document.getElementById('checkboxSpecial')?.checked) { // Check if the user has Hide Columns script's S/H column
const shColumn = document.createElement('td');
shColumn.textContent = segment.shadowHidden || segment.hidden ? '❌' : '—';
row.appendChild(shColumn);
}
return row;
}
/**
* Creates a span element with a title and text content
* @param {string} title Title for the span
* @param {string} content Text to be put in the span
* @returns {HTMLSpanElement}
*/
function createSpan(title, content) {
const newSpan = document.createElement('span');
newSpan.title = title;
newSpan.textContent = content;
return newSpan;
}
/**
* Creates a td element with a textare, a button, and a link.
* @param {string} value Value of the textarea
* @param {string} name Name for the textarea. Is the same as the column's header.
* @returns {HTMLTableCellElement}
*/
function createTextAreaColumnElement(value, name) {
const tdEl = document.createElement('td');
const textAreaEl = document.createElement('textarea');
textAreaEl.textContent = value;
textAreaEl.className = 'form-control';
textAreaEl.name = name;
textAreaEl.readOnly = true;
const buttonEl = document.createElement('button');
buttonEl.textContent = '✂';
buttonEl.addEventListener('click', () => GM_setClipboard(value));
const linkEl = document.createElement('a');
linkEl.textContent = '🔗';
linkEl.href = `/${name.toLowerCase()}/${value}/`;
tdEl.append(textAreaEl, buttonEl, linkEl);
return tdEl;
}
/**
* Checks if the user is a VIP and appends the crown symbol to the given element.
* @param {HTMLTableCellElement} votesColumn The element we want to append the crown symbol to
* @param {string} userID The userID of the user we want to check
*/
function getAndAddVipStatus(votesColumn, userID) {
GM_xmlhttpRequest({
method: 'GET',
url: `${BASEAPIURL}/userinfo?publicUserID=${userID}&value=vip`,
responseType: 'json',
timeout: 10000,
onload: (res) => {
if (res.status !== 200) {
console.warn('Couldn\'t get VIP status. API returned status ' + res.status);
appendRetryButtonToColumn(votesColumn, userID, 'Failed to get VIP status. Press to retry', getAndAddVipStatus);
return;
}
if (res.response.vip) {
votesColumn.appendChild(createSpan('This user is a VIP', '👑'));
}
},
onerror: () => {
console.warn('Couldn\'t get VIP status due to an error');
appendRetryButtonToColumn(votesColumn, userID, 'Failed to get VIP status. Press to retry', getAndAddVipStatus);
},
ontimeout: () => {
console.warn('Couldn\'t get VIP status due to timeout');
appendRetryButtonToColumn(votesColumn, userID, 'Failed to get VIP status. Press to retry', getAndAddVipStatus);
}
});
}
/**
* Gets the username of the specified user and adds it to the given element.
* @param {HTMLTableCellElement} usernameColumn The element we want to add the username to
* @param {string} userID The userID of the user whose username we want
*/
function getAndAddUsername(usernameColumn, userID) {
GM_xmlhttpRequest({
method: 'GET',
url: `${BASEAPIURL}/userinfo?publicUserID=${userID}&value=userName`,
responseType: 'json',
timeout: 10000,
onload: (res) => {
if (res.status !== 200) {
console.warn('Couldn\'t get username. API returned status ' + res.status);
appendRetryButtonToColumn(usernameColumn, userID, 'Failed to get username. Press to retry', getAndAddUsername);
return;
}
if (res.response.userName === userID) {
return;
}
usernameColumn.replaceWith(createTextAreaColumnElement(res.response.userName, 'Username'));
},
onerror: () => {
console.warn('Couldn\'t get username due to an error');
appendRetryButtonToColumn(usernameColumn, userID, 'Failed to get username. Press to retry', getAndAddUsername);
},
ontimeout: () => {
console.warn('Couldn\'t get username due to timeout');
appendRetryButtonToColumn(usernameColumn, userID, 'Failed to get username. Press to retry', getAndAddUsername);
}
});
}
/**
* Appends a retry button the provided element.
* @param {HTMLTableCellElement} columnEl The element we want to append the retry button to
* @param {string} userID The userID of the user we want to check
* @param {string} btnMsg The message we want to display for the button on hover
* @param {function} functionToCallOnRetry The function to call on retry. The function will be called with columnEl and userID as parameters
*/
function appendRetryButtonToColumn(columnEl, userID, btnMsg, functionToCallOnRetry) {
let retryBtn = createSpan(btnMsg, ' ↻');
retryBtn.addEventListener('click', () => {
columnEl.removeChild(retryBtn);
functionToCallOnRetry(columnEl, userID);
});
retryBtn.style.cursor = 'pointer';
columnEl.appendChild(retryBtn);
}
/**
* Fixes/adds the "even" "odd" classes of the table's rows.
* Also Adds display:none to columns that are hidden with the Hide Columns userscript
* @param {HTMLTableSectionElement} tbody Tbody element of the table
*/
function fixRows(tbody) {
[...tbody.children].forEach((row, i) => {
row.className = i % 2 ? 'odd' : 'even';
const columnHeaders = [...tbody.parentElement.querySelectorAll('thead th')];
columnHeaders.forEach((header, j) => {
if (header.style.display === 'none') {
row.children[j].style.display = 'none';
}
});
});
}
/**
* Adds the refresh button to the page.
*/
function addRefreshButton() {
const btn = document.createElement('button');
btn.className = 'btn btn-primary';
btn.innerText = 'Force Refresh';
btn.title = 'You can also press the "R" key on your keyboard';
btn.onclick = () => {
const videoID = checkPageVideoID();
if (!videoID) return;
getAndUpdateSegments(videoID);
getAndUpdateLocks(videoID);
};
const referenceElArray = document.getElementsByClassName('col-auto');
const referenceEl = referenceElArray[referenceElArray.length - 2];
const parentEl = referenceEl.parentElement;
const copyEl = referenceEl.cloneNode();
copyEl.appendChild(btn);
parentEl.appendChild(copyEl);
}
/**
* Gets the ID of the video on the page and returns it if found
* @returns {null | string}
*/
function checkPageVideoID() {
const pathName = location.pathname.slice(1, -1).split('/');
let videoID;
if (pathName[0] === 'uuid') {
const url = new URL(document.getElementsByClassName('float-end ms-1')[0]?.href ?? 'https://www.example.com');
videoID = url.pathname.slice(1, -1).split('/')[1];
if (videoID?.length === 0) {
window.alert('Could not get the video ID');
return null;
}
} else {
videoID = pathName[1];
}
return videoID;
}
})();
// ==UserScript==
// @name Hide columns on sb.ltn.fi
// @namespace sb.ltn.fi.hide.columns
// @version 1.1.7
// @description Hide any column you want on sb.ltn.fi
// @author TheJzoli
// @match https://sb.ltn.fi/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// ==/UserScript==
(function() {
'use strict';
// Create settings button and menu.
const navBar = [...document.getElementsByClassName('navbar')][0].lastElementChild;
const buttonDiv = document.createElement('div');
buttonDiv.style = 'position:relative;';
const settingsBtn = document.createElement('button');
settingsBtn.style = 'font-size:1.5em;background-color:transparent;border-style:none;transition-duration:500ms;';
settingsBtn.innerText = '⚙️';
const settingsMenuEl = document.createElement('div');
settingsMenuEl.classList.add('bg-light');
settingsMenuEl.style = 'position:absolute;z-index:999;top:51px;left:50%;transform:translateX(-50%);padding:10px;border-radius:5px;box-shadow:4px 5px 5px black;white-space:nowrap;display:none;';
settingsMenuEl.innerHTML = '<b>Column visibility:</b><br>';
settingsBtn.onclick = () => {
settingsBtn.style.transform = (settingsMenuEl.style.display) ? 'rotate(180deg)' : 'rotate(-180deg)';
settingsMenuEl.style.display = (settingsMenuEl.style.display) ? null : 'none';
settingsBtn.blur();
moveElementIntoViewport(settingsMenuEl);
}
buttonDiv.appendChild(settingsBtn);
buttonDiv.appendChild(settingsMenuEl);
navBar.insertBefore(buttonDiv, navBar.lastElementChild);
window.addEventListener('resize', () => { moveElementIntoViewport(settingsMenuEl) });
const observableEl = document.getElementById('navbarNav');
if (observableEl) {
const observer = new MutationObserver(() => { moveElementIntoViewport(settingsMenuEl) });
observer.observe(observableEl, {
attributes: true,
attributeFilter: ['class']
});
}
[...document.querySelectorAll('table')].forEach(table => {
const headersText = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim());
getColumnData(headersText).then(columnsObj => {
const rows = [...table.querySelectorAll('tbody tr')];
const headers = [...table.querySelectorAll('thead th')];
if (columnsObj.special) {
combineShadowhiddenAndHiddenColumns(headersText, rows, headers, table);
}
// long forEach loop starts
headersText.forEach((headerText, i) => {
// Hide or show columns based on previously saved data.
if (columnsObj[headerText] == false) {
headers[i].style.display = 'none';
rows.forEach(row => {
row.children[i].style.display = 'none';
});
} else if (columnsObj[headerText] == true) {
headers[i].style.display = null;
rows.forEach(row => {
row.children[i].style.display = null;
});
}
// Populate settings menu's checkbox options based on previously saved data.
const checkboxInputEl = document.createElement('input');
checkboxInputEl.setAttribute('type', 'checkbox');
checkboxInputEl.setAttribute('id', `checkbox${i+1}`);
checkboxInputEl.checked = columnsObj[headerText];
checkboxInputEl.style.marginRight = '10px';
const labelEl = document.createElement('label');
labelEl.setAttribute('for', `checkbox${i+1}`);
labelEl.innerText = headerText;
const brEl = document.createElement('br');
settingsMenuEl.appendChild(checkboxInputEl);
settingsMenuEl.appendChild(labelEl);
settingsMenuEl.appendChild(brEl);
});
// long forEach loop ends
// Create save button and its functionality.
const saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-primary';
saveBtn.innerText = 'Save';
saveBtn.onclick = async () => {
saveBtn.disabled = true;
const newColumnsObj = {};
const specialCheckboxInputEl = document.getElementById('checkboxSpecial');
newColumnsObj.special = specialCheckboxInputEl.checked;
const upToDateRows = [...table.querySelectorAll('tbody tr')];
if (newColumnsObj.special) {
combineShadowhiddenAndHiddenColumns(headersText, upToDateRows, headers, table);
} else {
separateShadowhiddenAndHiddenColumns(upToDateRows, table);
}
headersText.forEach((headerText, i) => {
const checkboxInputEl = document.getElementById(`checkbox${i+1}`);
newColumnsObj[headerText] = checkboxInputEl.checked;
if (newColumnsObj[headerText] == false) {
headers[i].style.display = 'none';
upToDateRows.forEach(row => {
row.children[i].style.display = 'none';
});
} else if (newColumnsObj[headerText] == true) {
headers[i].style.display = null;
upToDateRows.forEach(row => {
row.children[i].style.display = null;
});
}
});
await GM_setValue('columns', JSON.stringify(newColumnsObj));
saveBtn.disabled = false;
}
settingsMenuEl.appendChild(saveBtn);
// Create checkbox that combines the shadowhidden and hidden columns.
const specialCheckboxInputEl = document.createElement('input');
specialCheckboxInputEl.setAttribute('type', 'checkbox');
specialCheckboxInputEl.setAttribute('id', 'checkboxSpecial');
specialCheckboxInputEl.checked = columnsObj.special;
specialCheckboxInputEl.style.marginRight = '10px';
specialCheckboxInputEl.style.marginLeft = '10px';
const specialLabelEl = document.createElement('label');
specialLabelEl.setAttribute('for', 'checkboxSpecial');
specialLabelEl.style.marginBottom = '0';
specialLabelEl.style.verticalAlign = 'middle';
specialLabelEl.innerHTML = 'Combine Shadowhidden<br>and Hidden columns';
const shadowhiddenIndex = headersText.indexOf('Shadowhidden');
const shadowhiddenInputEl = document.getElementById(`checkbox${shadowhiddenIndex+1}`);
const hiddenIndex = headersText.indexOf('Hidden');
const hiddenInputEl = document.getElementById(`checkbox${hiddenIndex+1}`);
if (specialCheckboxInputEl.checked) {
shadowhiddenInputEl.disabled = true;
hiddenInputEl.disabled = true;
}
specialCheckboxInputEl.onchange = () => {
if (specialCheckboxInputEl.checked) {
shadowhiddenInputEl.checked = false;
shadowhiddenInputEl.disabled = true;
hiddenInputEl.checked = false;
hiddenInputEl.disabled = true;
} else {
shadowhiddenInputEl.disabled = false;
shadowhiddenInputEl.checked = true;
hiddenInputEl.disabled = false;
hiddenInputEl.checked = true;
}
}
settingsMenuEl.appendChild(specialCheckboxInputEl);
settingsMenuEl.appendChild(specialLabelEl);
}).catch(error => console.warn(`${error}`));
});
})();
async function getColumnData(headersText) {
//await GM_deleteValue('columns'); Uncomment this and load the page, if you want to delete the data you have saved in browser.
const columns = JSON.parse(await GM_getValue('columns', '{}'));
if (!columns) {
console.warn('Error! JSON.parse failed. Saved column data is invalid.');
columns = {};
}
headersText.forEach(headerText => {
const headerValue = columns[headerText];
if (headerValue == undefined) {
columns[headerText] = true;
}
});
columns.special = (columns.special == undefined) ? false : columns.special;
return columns;
}
function combineShadowhiddenAndHiddenColumns(headersText, rows, headers, table) {
const newHeadersText = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim());
if (newHeadersText.includes('S/H')) {
return;
}
const shadowhiddenValuesArray = [];
headersText.forEach((headerText, i) => {
if (headerText == 'Shadowhidden') {
rows.forEach(row => {
if (row.children[i].innerText == '—') {
shadowhiddenValuesArray.push(false);
} else {
shadowhiddenValuesArray.push(true);
};
});
}
});
headersText.forEach((headerText, i) => {
if (headerText == 'Hidden') {
rows.forEach((row, j) => {
if (row.children[i].innerText !== '—') {
shadowhiddenValuesArray[j] = true;
}
});
}
});
const theadTrEl = headers[0].parentNode;
let newHeader;
headers.forEach(header => {
if (header.textContent.trim() === 'Hidden') {
newHeader = header.cloneNode(true);
}
});
if (headersText.includes('Videoid')) { // We are on the main page
newHeader.innerText = 'S/H';
} else {
newHeader.lastElementChild.innerText = 'S/H';
}
newHeader.style.display = null;
theadTrEl.appendChild(newHeader);
rows.forEach((row, i) => {
const newRowEl = document.createElement('td');
newRowEl.innerText = (shadowhiddenValuesArray[i]) ? '❌' : '—';
row.appendChild(newRowEl);
});
}
function separateShadowhiddenAndHiddenColumns(rows, table) {
const newHeadersText = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim());
if (!newHeadersText.includes('S/H')) {
return;
}
const thead = [...table.querySelectorAll('thead')][0];
thead.lastElementChild.lastElementChild.remove();
rows.forEach(row => {
row.lastElementChild.remove();
});
}
function moveElementIntoViewport(element) {
const documentWidth = document.documentElement.clientWidth;
const rect = element.getBoundingClientRect();
if (documentWidth < element.offsetWidth) {
return;
}
if (rect.left < 15 && rect.left !== 0) {
element.style.transform = null;
const rectNew = element.getBoundingClientRect();
element.style.transform = `translateX(-${rectNew.left - 15}px)`;
} else if (rect.left >= 16 && rect.right <= (documentWidth - 16) && rect.left !== 0) {
element.style.transform = 'translateX(-50%)';
} else if (rect.right > documentWidth) {
element.style.transform = 'translateX(-50%)';
const rectNew = element.getBoundingClientRect();
if (rectNew.right > documentWidth - 15) {
element.style.transform = null;
const rectNewer = element.getBoundingClientRect();
element.style.transform = `translateX(-${rectNewer.right - (documentWidth - 15)}px)`;
}
}
}
// ==UserScript==
// @name Page navigation above the table on sb.ltn.fi
// @namespace sb.ltn.fi.above.table.page.navigation
// @version 1.0.3
// @description Duplicates the page navigation element and puts it above the table so you don't have to scroll down to change the page.
// @author TheJzoli
// @match https://sb.ltn.fi/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
const navigationElClone = document.querySelector('[aria-label="Table navigation"]')?.cloneNode(true);
if (navigationElClone) {
navigationElClone.lastElementChild.style.marginBottom = '0';
const tableEl = document.getElementsByClassName('table')[0];
const tableContainerEl = tableEl.parentElement;
tableContainerEl.insertBefore(navigationElClone, tableEl);
}
})();
// ==UserScript==
// @name Video Titles for sb.ltn.fi
// @namespace sb.ltn.fi.video.titles
// @version 1.2.10
// @description Replaces the video ID with the video title in the 'Video ID' column.
// @author TheJzoli
// @match https://sb.ltn.fi/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect sponsor.ajay.app
// @connect invidious.snopyta.org
// @connect invidious.namazso.eu
// @connect invidious.mutahar.rocks
// @connect vid.puffyan.us
// @connect yt.didw.to
// @connect y.com.cm
// @connect ytb.trom.tf
// @connect invidious.osi.kr
// @connect inv.riverside.rocks
// @connect invidio.xamh.de
// ==/UserScript==
// In case some of these don't work anymore,
// replace them with ones from here: https://docs.invidious.io/instances/
// Remember to add a @connect for the new url.
const URLS = ['https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://invidious.snopyta.org/api/v1/videos/',
'https://invidious.namazso.eu/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://invidious.mutahar.rocks/api/v1/videos/',
'https://vid.puffyan.us/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://yt.didw.to/api/v1/videos/',
'https://y.com.sb/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://ytb.trom.tf/api/v1/videos/',
'https://invidious.osi.kr/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://inv.riverside.rocks/api/v1/videos/',
'https://invidio.xamh.de/api/v1/videos/'];
const URLS_LENGTH = URLS.length;
// Change this if you change the frequency that sponsor.ajay.app is called. 0 if not at all.
const AJAY_URL_EVERY_X_CALL = 3;
const UrlWorks = [];
const videoIdAndRowElementObj = {};
(function() {
'use strict';
URLS.forEach(() => {UrlWorks.push(true)});
const animationCss = `
.loading {
display: inline-block;
vertical-align: middle;
width: 1em;
height: 1em;
margin-left: 0.5em;
}
.loading::after {
content: ' ';
display: block;
width: 0.9em;
height: 0.9em;
border-radius: 50%;
border: 0.1em solid #fff;
border-color: #cccc #cccc #cccc transparent;
animation: loader 1.2s linear infinite;
}
@keyframes loader {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}`;
GM_addStyle(animationCss);
[...document.querySelectorAll('table')].forEach(table => {
const headers = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim());
if (headers.includes('Video ID') || headers.includes('Videoid')) {
const columnIndex = headers.includes('Video ID') ? headers.indexOf('Video ID') : headers.indexOf('Videoid');
if (headers.includes('Video ID')) {
[...table.querySelectorAll('thead th')][columnIndex].children[0].innerText = "Video";
} else {
[...table.querySelectorAll('thead th')][columnIndex].innerText = "Video";
}
const rows = [...table.querySelectorAll('tbody tr')];
rows.forEach(row => {
const videoIdEl = row.children[columnIndex].firstChild;
const loadingEl = document.createElement('span');
loadingEl.classList.add("loading");
videoIdEl.appendChild(loadingEl);
const videoID = videoIdEl.innerText.trim();
if (videoID in videoIdAndRowElementObj) {
videoIdAndRowElementObj[videoID].push(videoIdEl);
} else {
videoIdAndRowElementObj[videoID] = [videoIdEl];
}
});
let index = 0;
for (const [key, value] of Object.entries(videoIdAndRowElementObj)) {
callApis(key, value, index);
if (index !== URLS_LENGTH - 1) {
index++;
} else {
index = 0;
}
}
}
});
})();
function callApis(videoID, videoIdElArray, index) {
// Every x call is to sponsor.ajay.app, which doesn't take the fields parameter.
let requestUrl = `${URLS[index]}${videoID}`;
if (index % AJAY_URL_EVERY_X_CALL !== 0) {
requestUrl += '?fields=title';
}
try {
GM_xmlhttpRequest({
method: 'GET',
url: requestUrl,
responseType: 'json',
timeout: 10000,
onload: (responseObject) => {
if (responseObject?.status !== 200 && responseObject?.status !== 304) {
changeUrlWorksValue(index, false);
console.log(`${requestUrl} returned a status of ${responseObject?.status}:\n${responseObject?.response?.error}\nUsing another instance...`);
// Try another random instance that works
const trueUrlsIndexes = [];
UrlWorks.forEach((bool, i) => {
if (bool) {
trueUrlsIndexes.push(i);
}
});
const randomIndex = Math.floor(Math.random() * trueUrlsIndexes.length);
// trueUrlsIndexes length is 0 if all are false
if (trueUrlsIndexes.length !== 0) {
callApis(videoID, videoIdElArray, trueUrlsIndexes[randomIndex]);
} else {
videoIdElArray.forEach(videoIdEl => {
videoIdEl.firstElementChild?.classList.remove('loading');
});
}
return;
}
changeUrlWorksValue(index, true);
// Inject the new name in place of the old video ID
if (responseObject?.response?.title) {
videoIdElArray.forEach(videoIdEl => {
videoIdEl.innerText = responseObject.response.title;
});
}
videoIdElArray.forEach(videoIdEl => {
videoIdEl.firstElementChild?.classList.remove('loading');
});
},
onerror: () => {
changeUrlWorksValue(index, false);
console.log(`${requestUrl} doesn't exist anymore.\nUsing another instance...`);
if (!UrlWorks.every(v => v === false)) {
if (index !== URLS_LENGTH - 1) {
callApis(videoID, videoIdElArray, index + 1);
} else {
callApis(videoID, videoIdElArray, 0);
}
} else {
videoIdElArray.forEach(videoIdEl => {
videoIdEl.firstElementChild?.classList.remove('loading');
});
}
},
ontimeout: () => {
console.log(`${requestUrl} timed out.\nUsing another instance...`);
if (!UrlWorks.every(v => v === false)) {
if (index !== URLS_LENGTH - 1) {
callApis(videoID, videoIdElArray, index + 1);
} else {
callApis(videoID, videoIdElArray, 0);
}
} else {
videoIdElArray.forEach(videoIdEl => {
videoIdEl.firstElementChild?.classList.remove('loading');
});
}
}
});
} catch (error) {
console.error(error);
}
}
function changeUrlWorksValue(index, bool) {
if (index % AJAY_URL_EVERY_X_CALL !== 0) {
UrlWorks[index] = bool;
} else {
let i = Math.ceil(URLS_LENGTH / AJAY_URL_EVERY_X_CALL) - 1;
for (; i >= 0; i--) {
UrlWorks[AJAY_URL_EVERY_X_CALL * i] = bool;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment