Compare commits
31 Commits
Author | SHA1 | Date |
---|---|---|
Simon | 1d27545409 | |
Simon | 7b40dc44c2 | |
Simon | 9ba90e4e45 | |
Simon | 0d58ddaaa2 | |
Vladimir Pouzanov | c82e493628 | |
Simon | ca15cc9c0b | |
Simon | 190f545ef2 | |
Ritiek Malhotra | 761030ca55 | |
Simon | 82a64ff4ba | |
Simon | 6a990ba11b | |
Simon | 988b2d59f4 | |
Simon | 999e86d637 | |
Kevin Gibbons | 92bef81e37 | |
Simon | 5987707b53 | |
Simon | f8d69f5883 | |
Simon | 4f54e1f863 | |
Kevin Gibbons | 75848ad4eb | |
Kevin Gibbons | ee6db2595f | |
Kevin Gibbons | 72c94fbe99 | |
Kevin Gibbons | c570aff66d | |
Simon | aaa04a43b5 | |
Simon | 976fefbf89 | |
Simon | 35186c09ca | |
Gautam krishna R | 160580a2a6 | |
Simon | 5406007315 | |
Simon | 79a002956b | |
Simon | f5f919dfef | |
Simon | dfaf7612ce | |
Simon | bcf8d205d4 | |
Simon | f0abc1af26 | |
crocs | 93ee803229 |
|
@ -6,6 +6,7 @@ module.exports = {
|
|||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true,
|
||||
},
|
||||
globals: {
|
||||
browser: 'readonly',
|
||||
|
@ -17,5 +18,7 @@ module.exports = {
|
|||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
curly: ['error', 'multi-line'],
|
||||
'no-var': 'error',
|
||||
'no-func-assign': 'off',
|
||||
'no-inner-declarations': 'off',
|
||||
},
|
||||
};
|
||||
|
|
19
README.md
19
README.md
|
@ -15,7 +15,7 @@ This is a browser extension to bridge YouTube with [Tube Archivist](https://gith
|
|||
- Sync your cookies for yt-dlp.
|
||||
|
||||
## Screenshots
|
||||
![popup screenshot](assets/screenshot.png?raw=true "Tube Archivist Companion Popup")
|
||||
![popup screenshot](assets/tac-screenshot.png?raw=true "Tube Archivist Companion Popup")
|
||||
Popup to enter your connection details.
|
||||
<br><br>
|
||||
|
||||
|
@ -23,12 +23,12 @@ Popup to enter your connection details.
|
|||
Button injected on video page to download the video or subscribe to the channel.
|
||||
<br><br>
|
||||
|
||||
![search page](assets/screenshot-search.png?raw=true "Tube Archivist Companion Search Page")
|
||||
Download button injected showing when hovering over top left corned of thumbnail
|
||||
![search page](assets/tac-screenshot-search.jpg?raw=true "Tube Archivist Companion Search Page")
|
||||
Download button injected showing when hovering over the video title.
|
||||
<br><br>
|
||||
|
||||
![channel page](assets/screenshot-channel.png?raw=true "Tube Archivist Companion Channel Page")
|
||||
Channel button injected to subscribe or download whole channel, video download button showing when hovering over topleft corner of thumbnail.
|
||||
![channel page](assets/tac-screenshot-channel.jpg?raw=true "Tube Archivist Companion Channel Page")
|
||||
Channel button injected to subscribe or download whole channel, video download button showing when hovering over the video title.
|
||||
<br>
|
||||
|
||||
## Install
|
||||
|
@ -54,7 +54,10 @@ A green checkmark will appear next to the *Save* button if your connection is wo
|
|||
- **Autostart**: Autostart and prioritize videos send from this extension.
|
||||
|
||||
## Test this extension
|
||||
Use the correct manifest file for your browser. Either rename the browser specific file to `manifest.json` before loading the addon or symlink it to the correct location, e.g. `ln -s manifest-firefox.json manifest.json`.
|
||||
Before continuing loading the temporary extension here, make sure to deactivate/delete the main extension first.
|
||||
|
||||
Symlink/copy the correct manifest file for your browser to the expected location, e.g. `ln -s manifest-firefox.json manifest.json`.
|
||||
|
||||
- Firefox
|
||||
- Open `about:debugging#/runtime/this-firefox`
|
||||
- Click on *Load Temporary Add-on*
|
||||
|
@ -74,6 +77,7 @@ Use the correct manifest file for your browser. Either rename the browser specif
|
|||
## Roadmap
|
||||
Join us on [Discord](https://www.tubearchivist.com/discord) and help us improve and extend this project. This is a list of planned features, in no particular order:
|
||||
- [ ] Implement download/subscribe button for playlists
|
||||
- [ ] Add download buttons to the `/shorts/` pages
|
||||
- [X] Get download and subscribe status from TA to show on the injected buttons
|
||||
- [X] Implement download button for videos on the YouTube homepage over inline preview
|
||||
- [X] Implement download button for videos on playlist
|
||||
|
@ -82,3 +86,6 @@ Join us on [Discord](https://www.tubearchivist.com/discord) and help us improve
|
|||
|
||||
## Making changes to the JavaScript
|
||||
The JavaScript does not require any build step; you just edit the files directly. However, there is config for eslint and prettier (a linter and formatter respectively); their use is recommended but not required. To use them, install `node`, run `npm i` from the root directory of this repository to install dependencies, then run `npm run lint` and `npm run format` to run eslint and prettier respectively.
|
||||
|
||||
## Updating Artwork
|
||||
Google listing is *very* picky. Screenshots need to be exactly **1280x800** in resolution and need to be in *jpg* or *png* without alpha canal.
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 490 KiB |
Binary file not shown.
Before Width: | Height: | Size: 199 KiB |
Binary file not shown.
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
After Width: | Height: | Size: 124 KiB |
|
@ -208,6 +208,7 @@ function buildCookieLine(cookie) {
|
|||
|
||||
async function sendCookies() {
|
||||
console.log('function sendCookies');
|
||||
const acceptableDomains = ['.youtube.com', 'youtube.com', 'www.youtube.com'];
|
||||
|
||||
let cookieStores = await browserType.cookies.getAllCookieStores();
|
||||
let cookieLines = [
|
||||
|
@ -223,7 +224,9 @@ async function sendCookies() {
|
|||
});
|
||||
for (let j = 0; j < allCookiesStore.length; j++) {
|
||||
const cookie = allCookiesStore[j];
|
||||
cookieLines.push(buildCookieLine(cookie));
|
||||
if (acceptableDomains.includes(cookie.domain)) {
|
||||
cookieLines.push(buildCookieLine(cookie));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
<a href="#" id="ta-url" target="_blank">
|
||||
<img src="/images/logo.png" alt="ta-logo">
|
||||
</a>
|
||||
<span>v0.2.1</span>
|
||||
<span>v0.3.1</span>
|
||||
</div>
|
||||
<hr>
|
||||
<form class="login-form">
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"manifest_version": 3,
|
||||
"name": "TubeArchivist Companion",
|
||||
"description": "Interact with your selfhosted TA server.",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.1",
|
||||
"icons": {
|
||||
"48": "/images/icon.png",
|
||||
"128": "/images/icon128.png"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"manifest_version": 2,
|
||||
"name": "TubeArchivist Companion",
|
||||
"description": "Interact with your selfhosted TA server.",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.1",
|
||||
"icons": {
|
||||
"128": "/images/icon128.png"
|
||||
},
|
||||
|
|
|
@ -38,6 +38,11 @@ function clearError() {
|
|||
errorOut.style.display = 'none';
|
||||
}
|
||||
|
||||
function clearTempLocalStorage() {
|
||||
browserType.storage.local.remove('popupApiKey');
|
||||
browserType.storage.local.remove('popupFullUrl');
|
||||
}
|
||||
|
||||
// store access details
|
||||
document.getElementById('save-login').addEventListener('click', function () {
|
||||
let url = document.getElementById('full-url').value;
|
||||
|
@ -78,6 +83,20 @@ document.getElementById('autostart').addEventListener('click', function () {
|
|||
toggleAutostart();
|
||||
});
|
||||
|
||||
let fullUrlInput = document.getElementById('full-url');
|
||||
fullUrlInput.addEventListener('change', () => {
|
||||
browserType.storage.local.set({
|
||||
popupFullUrl: fullUrlInput.value,
|
||||
});
|
||||
});
|
||||
|
||||
let apiKeyInput = document.getElementById('api-key');
|
||||
apiKeyInput.addEventListener('change', () => {
|
||||
browserType.storage.local.set({
|
||||
popupApiKey: apiKeyInput.value,
|
||||
});
|
||||
});
|
||||
|
||||
function sendCookie() {
|
||||
console.log('popup send cookie');
|
||||
clearError();
|
||||
|
@ -124,6 +143,7 @@ function toggleAutostart() {
|
|||
// send ping message to TA backend
|
||||
function pingBackend() {
|
||||
clearError();
|
||||
clearTempLocalStorage();
|
||||
function handleResponse() {
|
||||
console.log('connection validated');
|
||||
setStatusIcon(true);
|
||||
|
@ -184,6 +204,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
function onGot(item) {
|
||||
if (!item.access) {
|
||||
console.log('no access details found');
|
||||
if (item.popupFullUrl != null && fullUrlInput.value === '') {
|
||||
fullUrlInput.value = item.popupFullUrl;
|
||||
}
|
||||
if (item.popupApiKey != null && apiKeyInput.value === '') {
|
||||
apiKeyInput.value = item.popupApiKey;
|
||||
}
|
||||
setStatusIcon(false);
|
||||
return;
|
||||
}
|
||||
|
@ -217,7 +243,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
document.getElementById('autostart').checked = true;
|
||||
}
|
||||
|
||||
browserType.storage.local.get('access', function (result) {
|
||||
browserType.storage.local.get(['access', 'popupFullUrl', 'popupApiKey'], function (result) {
|
||||
onGot(result);
|
||||
});
|
||||
|
||||
|
|
|
@ -106,7 +106,7 @@ function getBrowser() {
|
|||
}
|
||||
|
||||
function getChannelContainers() {
|
||||
const elements = document.querySelectorAll('#inner-header-container, #owner');
|
||||
const elements = document.querySelectorAll('.yt-flexible-actions-view-model-wiz, #owner');
|
||||
const channelContainerNodes = [];
|
||||
|
||||
elements.forEach(element => {
|
||||
|
@ -135,69 +135,87 @@ function ensureTALinks() {
|
|||
|
||||
let titleContainerNodes = getTitleContainers();
|
||||
for (let titleContainer of titleContainerNodes) {
|
||||
if (titleContainer.hasTA) continue;
|
||||
let parent = getNearestH3(titleContainer);
|
||||
if (!parent) continue;
|
||||
if (parent.hasTA) continue;
|
||||
let videoButton = buildVideoButton(titleContainer);
|
||||
if (videoButton == null) continue;
|
||||
processTitle(titleContainer);
|
||||
titleContainer.appendChild(videoButton);
|
||||
titleContainer.hasTA = true;
|
||||
processTitle(parent);
|
||||
parent.appendChild(videoButton);
|
||||
parent.hasTA = true;
|
||||
}
|
||||
}
|
||||
ensureTALinks = throttled(ensureTALinks, 700);
|
||||
|
||||
// fix positioning of #owner div to fit new button
|
||||
function adjustOwner(channelContainer) {
|
||||
let sponsorButton = channelContainer.querySelector('#sponsor-button');
|
||||
if (sponsorButton === null) {
|
||||
return channelContainer;
|
||||
}
|
||||
|
||||
let variableMinWidth;
|
||||
if (sponsorButton.hasChildNodes()) {
|
||||
variableMinWidth = '140px';
|
||||
} else {
|
||||
variableMinWidth = '45px';
|
||||
}
|
||||
|
||||
Object.assign(channelContainer.firstElementChild.style, {
|
||||
minWidth: variableMinWidth,
|
||||
});
|
||||
Object.assign(channelContainer.style, {
|
||||
minWidth: 'calc(40% + 50px)',
|
||||
});
|
||||
return channelContainer;
|
||||
return channelContainer.querySelector('#buttons') || channelContainer;
|
||||
}
|
||||
|
||||
function buildChannelButton(channelContainer) {
|
||||
let channelHandle = getChannelHandle(channelContainer);
|
||||
channelContainer.taDerivedHandle = channelHandle;
|
||||
|
||||
let buttonDiv = buildChannelButtonDiv();
|
||||
|
||||
let channelSubButton = buildChannelSubButton(channelHandle);
|
||||
buttonDiv.appendChild(channelSubButton);
|
||||
channelContainer.taSubButton = channelSubButton;
|
||||
|
||||
let spacer = buildSpacer();
|
||||
buttonDiv.appendChild(spacer);
|
||||
|
||||
let channelDownloadButton = buildChannelDownloadButton();
|
||||
buttonDiv.appendChild(channelDownloadButton);
|
||||
channelContainer.taDownloadButton = channelDownloadButton;
|
||||
|
||||
if (!channelContainer.taObserver) {
|
||||
function updateButtonsIfNecessary() {
|
||||
let newHandle = getChannelHandle(channelContainer);
|
||||
if (channelContainer.taDerivedHandle === newHandle) return;
|
||||
console.log(`updating handle from ${channelContainer.taDerivedHandle} to ${newHandle}`);
|
||||
channelContainer.taDerivedHandle = newHandle;
|
||||
let channelSubButton = buildChannelSubButton(newHandle);
|
||||
channelContainer.taSubButton.replaceWith(channelSubButton);
|
||||
channelContainer.taSubButton = channelSubButton;
|
||||
|
||||
let channelDownloadButton = buildChannelDownloadButton();
|
||||
channelContainer.taDownloadButton.replaceWith(channelDownloadButton);
|
||||
channelContainer.taDownloadButton = channelDownloadButton;
|
||||
}
|
||||
channelContainer.taObserver = new MutationObserver(throttled(updateButtonsIfNecessary, 100));
|
||||
channelContainer.taObserver.observe(channelContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
return buttonDiv;
|
||||
}
|
||||
|
||||
function getChannelHandle(channelContainer) {
|
||||
const channelHandleContainer = document.querySelector('#channel-handle');
|
||||
let channelHandle = channelHandleContainer ? channelHandleContainer.innerText : null;
|
||||
if (!channelHandle) {
|
||||
let href = channelContainer.querySelector('.ytd-video-owner-renderer').href;
|
||||
const urlObj = new URL(href);
|
||||
channelHandle = urlObj.pathname.split('/')[1];
|
||||
let channelHandle;
|
||||
const videoOwnerRenderer = channelContainer.querySelector('.ytd-video-owner-renderer');
|
||||
|
||||
if (!videoOwnerRenderer) {
|
||||
const channelHandleContainer = document.querySelector(
|
||||
'.yt-content-metadata-view-model-wiz__metadata-text'
|
||||
);
|
||||
channelHandle = channelHandleContainer ? channelHandleContainer.innerText : null;
|
||||
} else {
|
||||
const href = videoOwnerRenderer.href;
|
||||
if (href) {
|
||||
const urlObj = new URL(href);
|
||||
channelHandle = urlObj.pathname.split('/')[1];
|
||||
}
|
||||
}
|
||||
|
||||
return channelHandle;
|
||||
}
|
||||
|
||||
function buildChannelButtonDiv() {
|
||||
let buttonDiv = document.createElement('div');
|
||||
buttonDiv.classList.add('ta-channel-button');
|
||||
|
||||
Object.assign(buttonDiv.style, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
@ -205,8 +223,8 @@ function buildChannelButtonDiv() {
|
|||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
padding: '5px',
|
||||
margin: '5px',
|
||||
borderRadius: '8px',
|
||||
'margin-left': '8px',
|
||||
borderRadius: '18px',
|
||||
});
|
||||
return buttonDiv;
|
||||
}
|
||||
|
@ -249,10 +267,10 @@ function checkChannelSubscribed(channelSubButton) {
|
|||
console.log('Unknown state');
|
||||
}
|
||||
}
|
||||
function handleError() {
|
||||
function handleError(e) {
|
||||
buttonError(channelSubButton);
|
||||
channelSubButton.innerText = 'Error';
|
||||
console.log('error');
|
||||
console.error('error', e);
|
||||
}
|
||||
|
||||
let channelHandle = channelSubButton.dataset.id;
|
||||
|
@ -274,7 +292,8 @@ function buildChannelDownloadButton() {
|
|||
let urlObj = new URL(currentLocation);
|
||||
|
||||
if (urlObj.pathname.startsWith('/watch')) {
|
||||
let videoId = urlObj.search.split('=')[1];
|
||||
let params = new URLSearchParams(document.location.search);
|
||||
let videoId = params.get('v');
|
||||
channelDownloadButton.setAttribute('data-type', 'video');
|
||||
channelDownloadButton.setAttribute('data-id', videoId);
|
||||
channelDownloadButton.title = `TA download video: ${videoId}`;
|
||||
|
@ -301,24 +320,40 @@ function buildChannelDownloadButton() {
|
|||
}
|
||||
|
||||
function getTitleContainers() {
|
||||
let nodes = document.querySelectorAll('#video-title');
|
||||
return nodes;
|
||||
let elements = document.querySelectorAll('#video-title');
|
||||
let videoNodes = [];
|
||||
elements.forEach(element => {
|
||||
if (isElementVisible(element)) {
|
||||
videoNodes.push(element);
|
||||
}
|
||||
});
|
||||
return elements;
|
||||
}
|
||||
|
||||
function getVideoId(titleContainer) {
|
||||
if (!titleContainer) return undefined;
|
||||
|
||||
let href = getNearestLink(titleContainer);
|
||||
if (!href) return;
|
||||
|
||||
let videoId;
|
||||
if (href.startsWith('/watch?v')) {
|
||||
let params = new URLSearchParams(href);
|
||||
videoId = params.get('/watch?v');
|
||||
} else if (href.startsWith('/shorts/')) {
|
||||
videoId = href.split('/')[2];
|
||||
}
|
||||
return videoId;
|
||||
}
|
||||
|
||||
function buildVideoButton(titleContainer) {
|
||||
let href = getNearestLink(titleContainer);
|
||||
let videoId = getVideoId(titleContainer);
|
||||
if (!videoId) return;
|
||||
|
||||
const dlButton = document.createElement('a');
|
||||
dlButton.classList.add('ta-button');
|
||||
dlButton.href = '#';
|
||||
|
||||
let params = new URLSearchParams(href);
|
||||
let videoId = params.get('/watch?v');
|
||||
if (!videoId) return;
|
||||
|
||||
dlButton.setAttribute('data-id', videoId);
|
||||
dlButton.setAttribute('data-type', 'video');
|
||||
dlButton.title = `TA download video: ${titleContainer.innerText} [${videoId}]`;
|
||||
|
||||
Object.assign(dlButton.style, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
@ -337,8 +372,8 @@ function buildVideoButton(titleContainer) {
|
|||
dlIcon.innerHTML = defaultIcon;
|
||||
Object.assign(dlIcon.style, {
|
||||
filter: 'invert()',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
width: '15px',
|
||||
height: '15px',
|
||||
padding: '7px 8px',
|
||||
});
|
||||
|
||||
|
@ -354,6 +389,24 @@ function buildVideoButton(titleContainer) {
|
|||
}
|
||||
|
||||
function getNearestLink(element) {
|
||||
// Check siblings
|
||||
let sibling = element;
|
||||
while (sibling) {
|
||||
sibling = sibling.previousElementSibling;
|
||||
if (sibling && sibling.tagName === 'A' && sibling.getAttribute('href') !== '#') {
|
||||
return sibling.getAttribute('href');
|
||||
}
|
||||
}
|
||||
|
||||
sibling = element;
|
||||
while (sibling) {
|
||||
sibling = sibling.nextElementSibling;
|
||||
if (sibling && sibling.tagName === 'A' && sibling.getAttribute('href') !== '#') {
|
||||
return sibling.getAttribute('href');
|
||||
}
|
||||
}
|
||||
|
||||
// Check parent elements
|
||||
for (let i = 0; i < 5 && element && element !== document; i++) {
|
||||
if (element.tagName === 'A' && element.getAttribute('href') !== '#') {
|
||||
return element.getAttribute('href');
|
||||
|
@ -363,8 +416,18 @@ function getNearestLink(element) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function getNearestH3(element) {
|
||||
for (let i = 0; i < 5 && element && element !== document; i++) {
|
||||
if (element.tagName === 'H3') {
|
||||
return element;
|
||||
}
|
||||
element = element.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function processTitle(titleContainer) {
|
||||
if (titleContainer.hasListener) return
|
||||
if (titleContainer.hasListener) return;
|
||||
Object.assign(titleContainer.style, {
|
||||
display: 'flex',
|
||||
gap: '15px',
|
||||
|
@ -401,12 +464,23 @@ function checkVideoExists(taButton) {
|
|||
}
|
||||
taButton.isChecked = true;
|
||||
}
|
||||
function handleError() {
|
||||
function handleError(e) {
|
||||
buttonError(taButton);
|
||||
console.log('error');
|
||||
let videoId = taButton.dataset.id;
|
||||
console.log(`error: failed to get info from TA for video ${videoId}`);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
let videoId = taButton.dataset.id;
|
||||
if (!videoId) {
|
||||
videoId = getVideoId(taButton);
|
||||
if (videoId) {
|
||||
taButton.setAttribute('data-id', videoId);
|
||||
taButton.setAttribute('data-type', 'video');
|
||||
taButton.title = `TA download video: ${taButton.parentElement.innerText} [${videoId}]`;
|
||||
}
|
||||
}
|
||||
|
||||
let message = { type: 'videoExists', videoId };
|
||||
let sending = sendMessage(message);
|
||||
sending.then(handleResponse, handleError);
|
||||
|
@ -460,9 +534,8 @@ function sendUrl(url, action, button) {
|
|||
}
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.log('error');
|
||||
console.log(JSON.stringify(error));
|
||||
function handleError(e) {
|
||||
console.log('error', e);
|
||||
buttonError(button);
|
||||
}
|
||||
|
||||
|
@ -495,15 +568,20 @@ function cleanButtons() {
|
|||
}
|
||||
|
||||
let oldHref = document.location.href;
|
||||
let throttleBlock;
|
||||
const throttle = (callback, time) => {
|
||||
if (throttleBlock) return;
|
||||
throttleBlock = true;
|
||||
setTimeout(() => {
|
||||
callback();
|
||||
throttleBlock = false;
|
||||
}, time);
|
||||
};
|
||||
|
||||
function throttled(callback, time) {
|
||||
let throttleBlock = false;
|
||||
let lastArgs;
|
||||
return (...args) => {
|
||||
lastArgs = args;
|
||||
if (throttleBlock) return;
|
||||
throttleBlock = true;
|
||||
setTimeout(() => {
|
||||
throttleBlock = false;
|
||||
callback(...lastArgs);
|
||||
}, time);
|
||||
};
|
||||
}
|
||||
|
||||
let observer = new MutationObserver(list => {
|
||||
const currentHref = document.location.href;
|
||||
|
@ -512,7 +590,7 @@ let observer = new MutationObserver(list => {
|
|||
oldHref = currentHref;
|
||||
}
|
||||
if (list.some(i => i.type === 'childList' && i.addedNodes.length > 0)) {
|
||||
throttle(ensureTALinks, 700);
|
||||
ensureTALinks();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue