Skip to content

Instantly share code, notes, and snippets.

@TheJzoli
Last active June 18, 2023 20:23
Show Gist options
  • Save TheJzoli/8a4cd979d433b7359cdf61c238bc0181 to your computer and use it in GitHub Desktop.
Save TheJzoli/8a4cd979d433b7359cdf61c238bc0181 to your computer and use it in GitHub Desktop.
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
  • sb.ltn.fi.vipinterface.user.js
    Adds option to lock categories and vote on segments.
    Original script by Deedit. Now being Upkept and updated by TheJzoli.
    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.16
// @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} ❓`));
// Remove the "no submissions exist" alert if it exists. This would be on the page if the page didn't have any segments before updating.
document.querySelector("[role=alert]")?.remove();
document.body.style.cursor = 'default';
// Fire event to signal other scripts of force refresh
const event = new CustomEvent("forceRefresh");
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, 32).replaceAll(' ', 'T') + 'Z');
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];
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));
});
fixRows(tableBody);
// Fire event to signal other scripts of new segments
if (newSegments.length !== 0) {
const event = new CustomEvent("newSegments");
document.dispatchEvent(event);
}
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 newCategory = segmentsArray[oldSegmentIndex].category;
const categoryEl = row.children[headers.indexOf('Category')];
const span = categoryEl.getElementsByTagName('span');
const categorySpanEl = span[0]?.id === 'colorSquare' ? span[1]: span[0]; // colored categories userscript or chapter span
const buttons = categoryEl.getElementsByTagName('button');
const categoryButton = buttons.length !== 0 && buttons[0].hasAttribute('id') ? buttons[0] : undefined; // VIP tools userscript
if (newCategory !== 'chapter') { // Chapters cannot be changed to other categories
if (categoryButton) { // Check for VIP tools userscript button
categoryButton.firstChild.textContent = newCategory;
}
if (categorySpanEl && categorySpanEl.classList[0] === 'mruy_sbcc') { // Check for colored categories userscript
const newClass = categorySpanEl.classList[1].slice(0, 10) + segmentsArray[oldSegmentIndex].category;
categorySpanEl.classList.replace(categorySpanEl.classList[1], newClass);
categorySpanEl.textContent = newCategory;
} else if (!categoryButton) { // If no colored categories, or vip tools userscripts.
if (categoryEl.firstElementChild?.hasAttribute('id')) { // check for colored square span
categoryEl.childNodes[1].textContent = newCategory;
} else { // if no other script that messes with the category column
categoryEl.firstChild.textContent = newCategory;
}
}
}
// 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. Date should conform to the ISO 8601 format "YYYY-MM-DDTHH:mm:ssZ"
* @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}`;
if (segment.category !== 'chapter') spanEl.textContent = segment.category;
categoryColumn.appendChild(spanEl);
}
});
});
if (segment.category === 'chapter' && hasCategoryColorsScript) { // chapter elements have an additional span in them to have the chapter's text appear in the title.
categoryColumn.firstElementChild.appendChild(createSpan(segment.description, segment.category)); // if category colors script is present another extra span is added and we need to account for that
} else if (segment.category === 'chapter') {
categoryColumn.appendChild(createSpan(segment.description, segment.category));
}
if (!hasCategoryColorsScript && categoryColumn.childElementCount === 0) 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');
switch (segment.actionType) {
case 'skip':
actiontypeColumn.appendChild(createSpan('Skip', '⏭'));
break;
case 'poi':
actiontypeColumn.appendChild(createSpan('Highlight', '✨️'));
break;
case 'full':
actiontypeColumn.appendChild(createSpan('Full video', '♾'));
break;
case 'mute':
actiontypeColumn.appendChild(createSpan('Mute', '🔇'));
break;
case 'chapter':
actiontypeColumn.appendChild(createSpan('Chapter', '🏷️'));
break;
default:
actiontypeColumn.appendChild(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 textarea, 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.10
// @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;transform-origin:50%55%;';
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 (headers[headersText.indexOf('VideoID')]?.firstElementChild === null) { // We are on the main page
newHeader.innerText = 'S/H';
newHeader.title = 'Shadowhidden/Hidden';
} else {
newHeader.lastElementChild.innerText = 'S/H';
newHeader.lastElementChild.title = 'Shadowhidden/Hidden';
}
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.12
// @description Replaces the video ID with the video title in the 'VideoID' 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.weblibre.org
// @connect vid.puffyan.us
// @connect invidious.flokinet.to
// @connect y.com.sb
// @connect invidious.tiekoetter.com
// @connect yt.artemislena.eu
// @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.
// I'll update the list every now and then when updating the userscript.
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://vid.puffyan.us/api/v1/videos/',
'https://invidio.xamh.de/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://inv.riverside.rocks/api/v1/videos/',
'https://invidious.weblibre.org/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://y.com.sb/api/v1/videos/',
'https://invidious.flokinet.to/api/v1/videos/',
'https://sponsor.ajay.app/api/youtubeApiProxy?key=8NpFUCMr2Gq4cy4UrUJPBfGBbRQudhJ8zzex8Gq44RYDywLt3UtbbfDap3KPDbcS&videoID=',
'https://invidious.tiekoetter.com/api/v1/videos/',
'https://yt.artemislena.eu/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 headersArray = [...table.querySelectorAll('thead th')];
const headers = headersArray.map(item => item.textContent.trim());
if (headers.includes('VideoID')) {
const columnIndex = headers.indexOf('VideoID');
if (headersArray[columnIndex].firstElementChild === null) { // If null, we're on the frontpage. If not, videopage.
headersArray[columnIndex].innerText = "Video";
} else {
headersArray[columnIndex].firstElementChild.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].concat(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}\nUsing another instance...`);
// Try another random instance that works
retryWithDifferentUrl(videoID, videoIdElArray);
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;
});
}
removeLoadingIcons(videoIdElArray);
},
onerror: () => {
changeUrlWorksValue(index, false);
console.log(`${requestUrl} doesn't exist anymore.\nUsing another instance...`);
retryWithDifferentUrl(videoID, videoIdElArray);
},
ontimeout: () => {
changeUrlWorksValue(index, false);
console.log(`${requestUrl} timed out.\nUsing another instance...`);
retryWithDifferentUrl(videoID, videoIdElArray);
}
});
} 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;
}
}
}
function removeLoadingIcons(videoIdElArray) {
videoIdElArray.forEach(videoIdEl => {
videoIdEl.firstElementChild?.classList.remove('loading');
});
}
function retryWithDifferentUrl (videoID, videoIdElArray) {
const trueUrlsIndexes = [];
UrlWorks.forEach((bool, i) => {
if (bool) trueUrlsIndexes.push(i);
});
// trueUrlsIndexes length is 0 if all are false
if (trueUrlsIndexes.length !== 0) {
const randomIndex = Math.floor(Math.random() * trueUrlsIndexes.length);
callApis(videoID, videoIdElArray, trueUrlsIndexes[randomIndex]);
} else {
removeLoadingIcons(videoIdElArray);
}
}
// ==UserScript==
// @name SponsorBlock Vip Interface
// @namespace sb.ltn.fi.vipinterface
// @version 1.0.12
// @description Adds option to lock categories and vote on segments.
// @author Deedit, TheJzoli
// @match https://sb.ltn.fi/*
// @connect sponsor.ajay.app
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @updateURL https://gist.github.com/TheJzoli/8a4cd979d433b7359cdf61c238bc0181/raw/sb.ltn.fi.vipinterface.user.js
// @downloadURL https://gist.github.com/TheJzoli/8a4cd979d433b7359cdf61c238bc0181/raw/sb.ltn.fi.vipinterface.user.js
// ==/UserScript==
// Thanks Hanabishi, blabdude and TheJzoli for the help and Nanobyte for having the acces to the category types in his colorcode userscript already done
// !!!!Original script by Deedit!!!! Being upkept by TheJzoli.
(async function() {
'use strict';
//consts
const userID = await GM_getValue("privateUserID", "");
const categoryListUI = ['Sponsor', 'Promo', 'Interaction', 'Intro', 'Outro', 'Preview', 'Filler', 'Music', 'POI'];
const categoryListAPI = ['sponsor', 'selfpromo', 'interaction', 'intro', 'outro', 'preview', 'filler', 'music_offtopic', 'poi_highlight'];
let UUIDsaver = '';
// create UI
createDropDownMenu();
categoryChange();
document.addEventListener('newSegments', () => {
categoryChange();
});
// Stuff for changing categories
function categoryChange(){
createCategoryButtons();
createCategorySelectorPopup();
}
function createCategoryButtons() {
[...document.querySelectorAll('table')].forEach(table => {
const headers = [...table.querySelectorAll('thead th')].map(item => item.textContent.trim());
if (headers.includes('Start') && headers.includes('End')) {
const columnIndex = headers.indexOf('Category');
const rows = [...table.querySelectorAll('tbody tr')];
rows.forEach((row, i) => {
// If this update is due to a newSegment event, we need to check for previous elements
const targetEl = row.children[columnIndex].lastElementChild;
if (targetEl && targetEl.hasAttribute('id')) {
targetEl.setAttribute('id', `lockChangeCategoryButton${i}`);
return;
}
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', `lockChangeCategoryButton${i}`);
buttonEl.width = "100%";
buttonEl.onclick = (event) => {
buttonEl.blur();
const oldUUIDsaver = UUIDsaver;
UUIDsaver = row.children[headers.indexOf('UUID')].firstElementChild.value;
// Only disable the popup, when the same UUID was pressed twice
if (oldUUIDsaver == UUIDsaver) {
const popup = document.getElementById('changeCategorySelector');
popup.style.display = (popup.style.display) ? null : 'none';
popup.style.left = (event.clientX + 90).toString() + 'px';
moveElementIntoViewport(popup);
} else {
const popup = document.getElementById('changeCategorySelector');
popup.style.left = (event.clientX + 90).toString() + 'px';
popup.style.display = null;
moveElementIntoViewport(popup);
}
}
//for normal use
if (targetEl === null){
const categoryEl = row.children[columnIndex];
const name = categoryEl.textContent.trim();
categoryEl.textContent = '';
buttonEl.innerText = name;
categoryEl.appendChild(buttonEl);
buttonEl.style.color = 'white';
} else { // So that it works properly with colored categories
targetEl.parentNode.insertBefore(buttonEl, targetEl);
buttonEl.appendChild(targetEl);
}
});
}});
}
function createCategorySelectorPopup() {
const categorySelectorEl = document.createElement('div');
//This is just, so that it is accessible via its ID
document.body.appendChild(categorySelectorEl);
categorySelectorEl.classList.add('bg-light');
categorySelectorEl.style = 'position:fixed;z-index:999;top:30%;left:44%;padding:10px;border-radius:5px;box-shadow:4px 5px 5px black;white-space:nowrap;display:none;';
categorySelectorEl.setAttribute('id', 'changeCategorySelector');
// Category Buttons
const categoryListUICopy = [...categoryListUI];
categoryListUICopy.splice(categoryListUI.indexOf('POI'), 1);
categoryListUICopy.forEach((name, i) => {
const labelEl = document.createTextNode(name);
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', `lockSelectCategoryButton${i}`);
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.style.padding = '0';
buttonEl.onclick = () => {
buttonEl.blur();
categorySelectorEl.style.display = 'none';
const categoryListAPICopy = [...categoryListAPI];
categoryListAPICopy.splice(categoryListAPI.indexOf('poi_highlight'), 1);
setCategory(UUIDsaver, categoryListAPICopy[i]);
}
buttonEl.appendChild(labelEl);
categorySelectorEl.appendChild(buttonEl);
categorySelectorEl.appendChild(document.createElement('br'));
});
// Close button
const labelCloseEl = document.createTextNode('❌');
const buttonCloseEl = document.createElement('BUTTON');
buttonCloseEl.className = 'btn';
buttonCloseEl.style.backgroundColor = 'black';
buttonCloseEl.style.width = '240px';
buttonCloseEl.style.margin = '2px 2px';
buttonCloseEl.style.padding = '0';
buttonCloseEl.setAttribute('id', `lockSelectCategoryCloseButton`);
buttonCloseEl.onclick = () => {
buttonCloseEl.blur();
categorySelectorEl.style.display = 'none';
}
buttonCloseEl.appendChild(labelCloseEl);
categorySelectorEl.appendChild(buttonCloseEl);
categorySelectorEl.appendChild(document.createElement('br'));
// Vote buttons
const voteNames = ['Upvote','Downvote','Undo vote'];
const voteCodes = [1,0,20];
voteNames.forEach((name, i) => {
const labelEl = document.createTextNode(name);
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', `lockSelectCategoryButton${i}`);
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.style.padding = '0';
buttonEl.onclick = () => {
buttonEl.blur();
categorySelectorEl.style.display = 'none';
voteOnSegment(UUIDsaver, voteCodes[i]);
}
buttonEl.appendChild(labelEl);
categorySelectorEl.appendChild(buttonEl);
categorySelectorEl.appendChild(document.createElement('br'));
});
window.addEventListener('resize', () => { moveElementIntoViewport(categorySelectorEl) });
}
function setCategory(UUID, category){
//console.log('https://sponsor.ajay.app/api/voteOnSponsorTime?UUID=' + UUID + '&userID=' + userID + '&category=' + category);
document.body.style.cursor = "progress";
GM_xmlhttpRequest({
method: 'GET',
url: 'https://sponsor.ajay.app/api/voteOnSponsorTime?UUID=' + UUID + '&userID=' + userID + '&category=' + category,
timeout: 10000,
onload: (res) => {
document.body.style.cursor = null;
if (!res) {
window.alert("Response object was undefined for some reason. Category was most likely not changed!");
return;
}
if (res.status !== 200 && res.status !== 304) {
window.alert(`Could not change category! Server responded with status ${res.status}: ${res.statusText ?? ""}`);
}
},
onerror: () => {
document.body.style.cursor = null;
window.alert("Unknown error occurred and category wasn't changed!");
},
ontimeout: () => {
document.body.style.cursor = null;
window.alert("Request timed out and category may not have been changed!");
}
});
}
function voteOnSegment(UUID, voteType){
document.body.style.cursor = "progress";
GM_xmlhttpRequest({
method: 'GET',
url: 'https://sponsor.ajay.app/api/voteOnSponsorTime?UUID=' + UUID + '&userID=' + userID + '&type=' + voteType,
timeout: 10000,
onload: (res) => {
document.body.style.cursor = null;
if (!res) {
window.alert("Response object was undefined for some reason. Vote most likely didn't go through!");
return;
}
if (res.status !== 200 && res.status !== 304) {
window.alert(`Vote didn't go through! Server responded with status ${res.status}: ${res.statusText ?? ""}`);
}
},
onerror: () => {
document.body.style.cursor = null;
window.alert("Unknown error occurred and segment wasn't voted!");
},
ontimeout: () => {
document.body.style.cursor = null;
window.alert("Request timed out and segment may not have been voted!");
}
});
}
// Stuff for categorie locks/ purge / clear cache
function createDropDownMenu() {
// Create Lock-symbol button and "dropdown"-menu.
const navBar = [...document.getElementsByClassName('navbar')][0].lastElementChild;
const lockButtonDiv = document.createElement('div');
lockButtonDiv.style = 'position:relative;';
const lockBtn = document.createElement('button');
lockBtn.style = 'font-size:1.5em;background-color:transparent;border-style:none;';
lockBtn.innerText = '🔒';
const lockMenuEl = document.createElement('div');
lockMenuEl.classList.add('bg-light');
lockMenuEl.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;';
lockBtn.onclick = () => {
//if (lockMenuEl.style.display === 'none') getReasons();
// null is visible, none is invisible
const menuVisibility = (lockMenuEl.style.display) ? null : 'none';
lockMenuEl.style.display = menuVisibility;
lockBtn.innerText = menuVisibility ? '🔒' : '🔓';
lockBtn.blur();
moveElementIntoViewport(lockMenuEl);
}
lockButtonDiv.appendChild(lockBtn);
lockButtonDiv.appendChild(lockMenuEl);
navBar.insertBefore(lockButtonDiv, navBar.lastElementChild);
// Create Dropdown elements
const lockCategorieRegex = new RegExp('https:\/\/sb\.ltn\.fi\/video\/*');
if (lockCategorieRegex.test(document.URL)) {
const videoID = document.URL.split('/')[4];
lockMenuEl.style.transform = 'translateX(-50%)';
createLockButton(lockMenuEl, videoID);
createCheckboxButton(lockMenuEl);
createLockTable(lockMenuEl);
createUnlockButton(lockMenuEl, videoID);
createSetUserIDButton(lockMenuEl);
lockMenuEl.appendChild(document.createElement('br'));
//createClearCacheButton(lockMenuEl, videoID);
//createPurgeSegmentsButton(lockMenuEl, videoID);
} else {
createSetUserIDButton(lockMenuEl);
lockMenuEl.style.transform = 'translateX(-50%)';
}
window.addEventListener('resize', () => { moveElementIntoViewport(lockMenuEl) });
const observableEl = document.getElementById('navbarNav');
if (observableEl) {
const observer = new MutationObserver(() => { moveElementIntoViewport(lockMenuEl) });
observer.observe(observableEl, {
attributes: true,
attributeFilter: ['class']
});
}
}
function createLockButton(lockMenuEl, videoID) {
// Create button that triggers category locking
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', 'lockLockCategoriesButton');
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.onclick = () => {
buttonEl.blur();
categoryListAPI.forEach((name,i) => {
if (document.getElementById(`lockCheckbox${i}`).checked) {
const lockReason = document.getElementById(`lockReason${i}`).value;
setReasons(name, lockReason, videoID);
}
});
}
const buttonText = document.createTextNode("Lock");
buttonEl.appendChild(buttonText);
lockMenuEl.appendChild(buttonEl);
}
function createCheckboxButton(lockMenuEl) {
// Create button that selects all checkboxes
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', 'lockSelectAll');
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.onclick = () => {
// Check whether all are checked.
let allTrue = true;
categoryListUI.forEach((name, i) => {
if (name != 'POI' && name != 'Filler') {
allTrue = allTrue && document.getElementById(`lockCheckbox${i}`).checked;
}
});
categoryListUI.forEach((name, i) => {
if (name != 'POI' && name != 'Filler') {
document.getElementById(`lockCheckbox${i}`).checked = (allTrue) ? false: true;
}
});
buttonEl.blur();
}
const buttonText = document.createTextNode("Select all");
buttonEl.appendChild(buttonText);
lockMenuEl.appendChild(buttonEl);
}
function createLockTable(lockMenuEl) {
// Create table that is inside the "dropdown"-menu
const tableBodyEl = document.createElement("TBODY");
const tableEl = document.createElement("TABLE");
lockMenuEl.appendChild(tableEl);
tableEl.appendChild(tableBodyEl);
// Create a table with checkboxes inside of labels and textareas
categoryListUI.forEach((name, i) => {
const tableRowEl = document.createElement("TR");
const tableLabelEl = document.createElement("TD");
tableLabelEl.style.userSelect = "none";
const tableReasonEl = document.createElement("TD");
const checkboxEl = document.createElement('input');
checkboxEl.setAttribute('type', 'checkbox');
checkboxEl.setAttribute('id', `lockCheckbox${i}`);
checkboxEl.style.marginLeft = '5px';
const labelEl = document.createElement('label');
labelEl.innerText = name;
labelEl.style.height = "75px";
labelEl.style.verticalAlign = "middle";
const reasonTextareaEl = document.createElement('TEXTAREA');
const reasonEl = document.createTextNode('');
reasonTextareaEl.setAttribute('id', `lockReason${i}`);
reasonTextareaEl.style.height = '75px';
reasonTextareaEl.style.width = '400px';
reasonTextareaEl.className = 'form-control';
reasonTextareaEl.appendChild(reasonEl);
labelEl.appendChild(checkboxEl);
tableLabelEl.appendChild(labelEl);
tableReasonEl.appendChild(reasonTextareaEl);
tableRowEl.appendChild(tableLabelEl);
tableRowEl.appendChild(tableReasonEl);
tableBodyEl.appendChild(tableRowEl);
});
}
function createUnlockButton(lockMenuEl, videoID) {
// Create button that triggers category UNlocking
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', 'lockUnlockCategoriesButton');
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.onclick = () => {
buttonEl.blur();
const names = [];
categoryListAPI.forEach((name,i) => {
if (document.getElementById(`lockCheckbox${i}`).checked) {
names.push(name);
}
});
unSetReasons(names, videoID);
}
const buttonText = document.createTextNode("Unlock");
buttonEl.appendChild(buttonText);
lockMenuEl.appendChild(buttonEl);
}
function createSetUserIDButton(lockMenuEl) {
// Button for the UserID
const buttonEl = document.createElement('BUTTON');
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.onclick = () => {
buttonEl.blur();
let tempUserID = prompt('Set private UserID')
if (tempUserID !== null) GM_setValue("privateUserID", tempUserID);
}
const buttonText = document.createTextNode("Set UserID");
buttonEl.appendChild(buttonText);
lockMenuEl.appendChild(buttonEl);
}
function createClearCacheButton(lockMenuEl, videoID) {
// Create button that triggers category UNlocking
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', 'lockClearCacheButton');
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.onclick = () => {
buttonEl.blur();
clearCache(videoID);
}
const buttonText = document.createTextNode("Clear cache");
buttonEl.appendChild(buttonText);
lockMenuEl.appendChild(buttonEl);
}
function createPurgeSegmentsButton(lockMenuEl, videoID) {
// Create button that triggers category UNlocking
const buttonEl = document.createElement('BUTTON');
buttonEl.setAttribute('id', 'lockPurgeSegmentsButton');
buttonEl.className = 'btn btn-secondary';
buttonEl.style.width = '240px';
buttonEl.style.margin = '2px 2px';
buttonEl.onclick = () => {
buttonEl.blur();
if (prompt('Write "yes" to confirm.') == 'yes') {
console.log('purged');
clearCache(videoID);
}
}
const buttonText = document.createTextNode("Purge segments");
buttonEl.appendChild(buttonText);
lockMenuEl.appendChild(buttonEl);
}
function setReasons (category, lockReason, videoID){
// Set all 8 categories lock reasons and locks, or it unlocks if checkbox isn't set
document.body.style.cursor = "progress";
document.getElementById("lockLockCategoriesButton").style.cursor = "progress";
GM_xmlhttpRequest({
method: 'POST',
url: 'https://sponsor.ajay.app/api/lockCategories',
data: JSON.stringify({
videoID: videoID,
userID: userID,
categories: [category],
reason: lockReason
}),
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
onload: (res) => {
document.body.style.cursor = null;
document.getElementById("lockLockCategoriesButton").style.cursor = null;
if (!res) {
window.alert(`Response object was undefined for some reason. ${category} was most likely not locked!`);
return;
}
if (res.status !== 200 && res.status !== 304) {
window.alert(`Could not lock ${category}! Server responded with status ${res.status}: ${res.statusText ?? ""}`);
}
},
onerror: () => {
document.body.style.cursor = null;
document.getElementById("lockLockCategoriesButton").style.cursor = null;
window.alert(`Unknown error occurred and ${category} wasn't locked!`);
},
ontimeout: () => {
document.body.style.cursor = null;
document.getElementById("lockLockCategoriesButton").style.cursor = null;
window.alert(`Request timed out and ${category} may not have been locked!`);
}
});
}
function unSetReasons (categories, videoID){
// Unlockes selectd categories
document.body.style.cursor = "progress";
document.getElementById("lockUnlockCategoriesButton").style.cursor = "progress";
GM_xmlhttpRequest({
method: 'DELETE',
url: 'https://sponsor.ajay.app/api/lockCategories',
data: JSON.stringify({
videoID: videoID,
userID: userID,
categories: categories
}),
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
onload: (res) => {
document.body.style.cursor = null;
document.getElementById("lockUnlockCategoriesButton").style.cursor = null;
if (!res) {
window.alert(`Response object was undefined for some reason. Categories were most likely not unlocked!`);
return;
}
if (res.status !== 200 && res.status !== 304) {
window.alert(`Could not unlock categories! Server responded with status ${res.status}: ${res.statusText ?? ""}`);
}
},
onerror: () => {
document.body.style.cursor = null;
document.getElementById("lockUnlockCategoriesButton").style.cursor = null;
window.alert(`Unknown error occurred and categories weren't unlocked!`);
},
ontimeout: () => {
document.body.style.cursor = null;
document.getElementById("lockUnlockCategoriesButton").style.cursor = null;
window.alert(`Request timed out and categories may not have been unlocked!`);
}
});
}
function getReasons() {
// Calls API and fills reasons in textareas
GM_xmlhttpRequest({
method: 'GET',
//url: `https://sponsor.ajay.app/api/lockCategories?videoID=${id}`,
url: `https://sponsor.ajay.app/api/lockCategories?videoID=qJeKZkK31JE`,
responseType: 'json',
onload: (res) => {
// https://wiki.greasespot.net/GM.xmlHttpRequest#Response_Object is what is returned
if(!res) return;
const d = document.createElement('div'); // TODO
let page = prompt(`Reason: ${res.status ?? ''}`);
},
});
}
function clearCache (videoID){
document.body.style.cursor = "progress";
document.getElementById("lockClearCacheButton").style.cursor = "progress";
GM_xmlhttpRequest({
method: 'POST',
url: 'https://sponsor.ajay.app/api/clearCache',
data: JSON.stringify({
videoID: videoID,
userID: userID
}),
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
onload: (res) => {
document.body.style.cursor = null;
document.getElementById("lockClearCacheButton").style.cursor = null;
if (!res) {
window.alert(`Response object was undefined for some reason. Cache was most likely not cleared!`);
return;
}
if (res.status !== 200 && res.status !== 304) {
window.alert(`Could not clear cache! Server responded with status ${res.status}: ${res.statusText ?? ""}`);
}
},
onerror: () => {
document.body.style.cursor = null;
document.getElementById("lockClearCacheButton").style.cursor = null;
window.alert(`Unknown error occurred and cache wasn't cleared!`);
},
ontimeout: () => {
document.body.style.cursor = null;
document.getElementById("lockClearCacheButton").style.cursor = null;
window.alert(`Request timed out and cache may not have been cleared!`);
}
});
}
function purgeSegments (videoID){
document.body.style.cursor = "progress";
document.getElementById("lockPurgeSegmentsButton").style.cursor = "progress";
GM_xmlhttpRequest({
method: 'POST',
url: 'https://sponsor.ajay.app/api/purgeAllSegments',
data: JSON.stringify({
videoID: videoID,
userID: userID
}),
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
onload: (res) => {
document.body.style.cursor = null;
document.getElementById("lockPurgeSegmentsButton").style.cursor = null;
if (!res) {
window.alert(`Response object was undefined for some reason. Segments were most likely not purged!`);
return;
}
if (res.status !== 200 && res.status !== 304) {
window.alert(`Could not purge segments! Server responded with status ${res.status}: ${res.statusText ?? ""}`);
}
},
onerror: () => {
document.body.style.cursor = null;
document.getElementById("lockPurgeSegmentsButton").style.cursor = null;
window.alert(`Unknown error occurred and segments weren't purged!`);
},
ontimeout: () => {
document.body.style.cursor = null;
document.getElementById("lockPurgeSegmentsButton").style.cursor = null;
window.alert(`Request timed out and segments may not have been purged!`);
}
});
}
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.right > documentWidth - 15) {
element.style.transform = null;
const rectNew = element.getBoundingClientRect();
element.style.transform = `translateX(-${rectNew.right - (documentWidth - 15)}px)`;
} else if (rect.left >= 16 && rect.right <= (documentWidth - 16) && rect.left !== 0) {
if (element.getAttribute('id') === 'changeCategorySelector') {
element.style.transform = null;
} else {
element.style.transform = 'translateX(-50%)';
}
const rectNew = element.getBoundingClientRect();
if (rectNew.right > documentWidth - 15) {
element.style.transform = `translateX(-${rectNew.right - (documentWidth - 15)}px)`;
}
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment