Compare commits

...

58 Commits

Author SHA1 Message Date
Simon 1d27545409
bump version 2024-05-15 20:47:38 +02:00
Simon 7b40dc44c2
fix checkVideoExists, check siblings in getNearestLink 2024-05-13 21:53:45 +02:00
Simon 9ba90e4e45
bump version 2024-05-11 17:05:25 +02:00
Simon 0d58ddaaa2
limit cookie domains, #22 (#38) 2024-05-11 22:02:46 +07:00
Vladimir Pouzanov c82e493628
Relocate the injected button to the h3 element (#37) 2024-05-11 22:00:29 +07:00
Simon ca15cc9c0b
fix channel page selector 2024-05-01 22:23:41 +02:00
Simon 190f545ef2
fix format linter 2024-05-01 22:23:26 +02:00
Ritiek Malhotra 761030ca55
Show checkmark for already downloaded currently playing videos (#35)
Looks like this was regressed from v0.2.1 to v0.2.2 in this commit sha:
f8d69f5883

Already downloaded video currently playing in YouTube were still
showing the download icon instead of checkmark icon in TA.

This commit should fix this.
2024-05-02 02:20:34 +07:00
Simon 82a64ff4ba
update roadmap, add shorts pages button 2023-11-10 11:41:30 +07:00
Simon 6a990ba11b
bump version 2023-11-10 11:23:08 +07:00
Simon 988b2d59f4
better testing instructions 2023-11-09 20:26:08 +07:00
Simon 999e86d637
add clearTempLocalStorage 2023-11-09 20:07:32 +07:00
Kevin Gibbons 92bef81e37
persist inputs in popup (#31)
* persist inputs in popup

* consolidate storage requests
2023-11-09 20:06:31 +07:00
Simon 5987707b53
Merge pull request #30 from bakkot/update-subscribe-button
Update subscribe button
video id on hover
2023-11-09 18:23:15 +07:00
Simon f8d69f5883
implement extract video id on hover 2023-11-09 18:21:43 +07:00
Simon 4f54e1f863
flip getChannelHandle logic, remove unvisible title containers 2023-11-08 18:01:50 +07:00
Kevin Gibbons 75848ad4eb fix infinite buttons 2023-11-04 21:39:03 -07:00
Kevin Gibbons ee6db2595f restore .eslintrc.js 2023-11-03 09:05:43 -07:00
Kevin Gibbons 72c94fbe99 better logic for updating the subscribe button on page navigation 2023-11-03 09:05:42 -07:00
Kevin Gibbons c570aff66d Revert "delay buildChannelButton to account for UI refresh on YT"
This reverts commit aaa04a43b5.
2023-11-03 09:00:39 -07:00
Simon aaa04a43b5
delay buildChannelButton to account for UI refresh on YT 2023-11-03 12:05:17 +07:00
Simon 976fefbf89
better fail to get TA info log message 2023-11-02 20:30:17 +07:00
Simon 35186c09ca
skip empty href container link building 2023-11-02 20:25:15 +07:00
Gautam krishna R 160580a2a6
updated the subscribe button styling to match the new youtube ui (#29)
* updated the  subscribe button styling to match new youtube ui

* added code watch page subscribe button
2023-11-02 20:11:25 +07:00
Simon 5406007315
add dl button to shorts results 2023-10-31 16:59:38 +07:00
Simon 79a002956b
reduce single button size 2023-10-31 16:48:58 +07:00
Simon f5f919dfef
fix video id extraction for url with additional query params 2023-10-19 10:25:10 +07:00
Simon dfaf7612ce
fix button location wording 2023-10-07 16:53:08 +07:00
Simon bcf8d205d4
fix jpg link 2023-10-07 16:51:39 +07:00
Simon f0abc1af26
resize screenshots 2023-10-07 16:50:19 +07:00
crocs 93ee803229
Updated Screenshots (#26)
* Delete assets/screenshot.png

* Delete assets/screenshot-search.png

* Delete assets/screenshot-channel.png

* Add files via upload

* Update README.md

* Delete assets/screenshot-channel.png

* Delete assets/screenshot.png

* Delete assets/screenshot-search.png

* Add files via upload

* Update README.md
2023-10-07 16:33:49 +07:00
Simon c3303a4d13
bump version 2023-09-21 17:37:26 +07:00
Merlin da4345a985
Fix video exists check missing port (#23) 2023-09-21 17:31:19 +07:00
Simon dc8fecf792
update readme, roadmap 2023-09-03 13:15:10 +07:00
Simon fd87615cdc
use current location for channel download url 2023-09-03 11:20:31 +07:00
Simon ad2a6f3693
bump version 2023-08-29 08:35:22 +07:00
Simon c79c0cc408
prevent double eventlistener 2023-08-26 22:51:21 +07:00
Simon f3064f32b1
implement open in TA 2023-08-26 22:39:37 +07:00
Simon 1306dbd6fa
handle button error 2023-08-26 20:14:27 +07:00
Simon 87ef597116
fix empty cache at first start 2023-08-26 20:05:29 +07:00
Simon ef89daf1a1
set unsubscribe inner text after subscribe 2023-08-26 17:38:39 +07:00
Simon 7c47c980f3
implement channel handle id map cache 2023-08-26 17:34:58 +07:00
Simon adde4c51c0
check channel subscribed 2023-08-25 19:28:42 +07:00
Simon 4112501900
rename channelSubButton 2023-08-25 18:47:43 +07:00
Simon 114548d362
better dl button naming 2023-08-25 18:46:29 +07:00
Simon 1fed4c32e2
restructure functions 2023-08-25 18:44:11 +07:00
Simon f9feee70d1
refactor channel buttons 2023-08-25 17:42:43 +07:00
Simon bf1c47843f
better video button title 2023-08-25 15:37:07 +07:00
Simon cd5e9a8c0a
better events 2023-08-24 21:48:34 +07:00
Simon c3079d81ff
download video 2023-08-24 21:40:23 +07:00
Simon 004067a1f7
implement checkVideoExists 2023-08-24 21:20:15 +07:00
Simon 10ecdaee23
trigger button clear when url changes 2023-08-24 18:52:39 +07:00
Simon f0ec9e23a7
mv video dl button in title container 2023-08-24 11:49:00 +07:00
simon 8cad2bcc22
fix typo 2023-05-10 21:06:30 +07:00
simon fe309560da
bump version 2023-05-10 21:05:17 +07:00
simon 0d611538a3
add autostart docs 2023-05-10 20:43:03 +07:00
simon 7ce3835ef3
add autostart option 2023-05-10 20:39:40 +07:00
simon 62fa12d218
update roadmap 2023-03-04 09:34:41 +07:00
15 changed files with 592 additions and 251 deletions

View File

@ -6,6 +6,7 @@ module.exports = {
}, },
env: { env: {
browser: true, browser: true,
es6: true,
}, },
globals: { globals: {
browser: 'readonly', browser: 'readonly',
@ -17,5 +18,7 @@ module.exports = {
eqeqeq: ['error', 'always', { null: 'ignore' }], eqeqeq: ['error', 'always', { null: 'ignore' }],
curly: ['error', 'multi-line'], curly: ['error', 'multi-line'],
'no-var': 'error', 'no-var': 'error',
'no-func-assign': 'off',
'no-inner-declarations': 'off',
}, },
}; };

View File

@ -11,11 +11,11 @@ This is a browser extension to bridge YouTube with [Tube Archivist](https://gith
- Add your Tube Archivist connection details in the addon popup. - Add your Tube Archivist connection details in the addon popup.
- On YouTube video pages, inject a download button to download that video and a subscribe button to subscribe to that channel. - On YouTube video pages, inject a download button to download that video and a subscribe button to subscribe to that channel.
- On YouTube channel pages, inject a button to subscribe to the channel or download the complete channel. Regarding the channel subpages, this follows the same rules as adding to the queue over the form. - On YouTube channel pages, inject a button to subscribe to the channel or download the complete channel. Regarding the channel subpages, this follows the same rules as adding to the queue over the form.
- Throughout most places, hover over the video to reveal a download button for that video. - Throughout most places, hover over the video title to reveal a download button for that video.
- Sync your cookies for yt-dlp. - Sync your cookies for yt-dlp.
## Screenshots ## 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. Popup to enter your connection details.
<br><br> <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. Button injected on video page to download the video or subscribe to the channel.
<br><br> <br><br>
![search page](assets/screenshot-search.png?raw=true "Tube Archivist Companion Search Page") ![search page](assets/tac-screenshot-search.jpg?raw=true "Tube Archivist Companion Search Page")
Download button injected showing when hovering over top left corned of thumbnail Download button injected showing when hovering over the video title.
<br><br> <br><br>
![channel page](assets/screenshot-channel.png?raw=true "Tube Archivist Companion Channel Page") ![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 topleft corner of thumbnail. Channel button injected to subscribe or download whole channel, video download button showing when hovering over the video title.
<br> <br>
## Install ## Install
@ -51,9 +51,13 @@ A green checkmark will appear next to the *Save* button if your connection is wo
## Options ## Options
- **Sync YouTube cookies**: Send your cookies to TubeArchivist to use for yt-dlp requests. - **Sync YouTube cookies**: Send your cookies to TubeArchivist to use for yt-dlp requests.
- **Autostart**: Autostart and prioritize videos send from this extension.
## Test 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 - Firefox
- Open `about:debugging#/runtime/this-firefox` - Open `about:debugging#/runtime/this-firefox`
- Click on *Load Temporary Add-on* - Click on *Load Temporary Add-on*
@ -72,12 +76,16 @@ Use the correct manifest file for your browser. Either rename the browser specif
## Roadmap ## 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: 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:
- [ ] Get download and subscribe status from TA to show on the injected buttons
- [ ] Implement download button for videos on the YouTube homepage over inline preview
- [ ] Implement download/subscribe button for playlists - [ ] Implement download/subscribe button for playlists
- [ ] Implement download button for videos on playlist - [ ] Add download buttons to the `/shorts/` pages
- [ ] Error handling for connection errors - [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
- [X] Error handling for connection errors
- [X] Dynamically inject buttons with mutation observer - [X] Dynamically inject buttons with mutation observer
## Making changes to the JavaScript ## 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. 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

BIN
assets/tac-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -101,8 +101,13 @@ async function verifyConnection() {
// send youtube link from injected buttons // send youtube link from injected buttons
async function download(url) { async function download(url) {
let apiURL = 'api/download/';
let autostart = await browserType.storage.local.get('autostart');
if (Object.keys(autostart).length > 0 && autostart.autostart.checked) {
apiURL += '?autostart=true';
}
return await sendData( return await sendData(
'api/download/', apiURL,
{ {
data: [ data: [
{ {
@ -115,14 +120,14 @@ async function download(url) {
); );
} }
async function subscribe(url) { async function subscribe(url, subscribed) {
return await sendData( return await sendData(
'api/channel/', 'api/channel/',
{ {
data: [ data: [
{ {
channel_id: url, channel_id: url,
channel_subscribed: true, channel_subscribed: subscribed,
}, },
], ],
}, },
@ -130,6 +135,55 @@ async function subscribe(url) {
); );
} }
async function videoExists(id) {
const path = `api/video/${id}/`;
let response = await sendGet(path);
if (!response.data) return false;
let access = await getAccess();
return new URL(`video/${id}/`, `${access.url}:${access.port}/`).href;
}
async function getChannelCache() {
let cache = await browserType.storage.local.get('cache');
if (cache.cache) return cache;
return { cache: {} };
}
async function setChannel(channelHandler, channelId) {
let cache = await getChannelCache();
cache.cache[channelHandler] = { id: channelId, timestamp: Date.now() };
browserType.storage.local.set(cache);
}
async function getChannelId(channelHandle) {
let cache = await getChannelCache();
if (cache.cache[channelHandle]) {
return cache.cache[channelHandle]?.id;
}
let channel = await searchChannel(channelHandle);
if (channel) setChannel(channelHandle, channel.channel_id);
return channel.channel_id;
}
async function searchChannel(channelHandle) {
const path = `api/channel/search/?q=${channelHandle}`;
let response = await sendGet(path);
return response.data;
}
async function getChannel(channelHandle) {
let channelId = await getChannelId(channelHandle);
if (!channelId) return;
const path = `api/channel/${channelId}/`;
let response = await sendGet(path);
return response.data;
}
async function cookieStr(cookieLines) { async function cookieStr(cookieLines) {
const path = 'api/cookie/'; const path = 'api/cookie/';
let payload = { let payload = {
@ -154,6 +208,7 @@ function buildCookieLine(cookie) {
async function sendCookies() { async function sendCookies() {
console.log('function sendCookies'); console.log('function sendCookies');
const acceptableDomains = ['.youtube.com', 'youtube.com', 'www.youtube.com'];
let cookieStores = await browserType.cookies.getAllCookieStores(); let cookieStores = await browserType.cookies.getAllCookieStores();
let cookieLines = [ let cookieLines = [
@ -169,7 +224,9 @@ async function sendCookies() {
}); });
for (let j = 0; j < allCookiesStore.length; j++) { for (let j = 0; j < allCookiesStore.length; j++) {
const cookie = allCookiesStore[j]; const cookie = allCookiesStore[j];
cookieLines.push(buildCookieLine(cookie)); if (acceptableDomains.includes(cookie.domain)) {
cookieLines.push(buildCookieLine(cookie));
}
} }
} }
@ -187,6 +244,9 @@ type Message =
| { type: 'sendCookie' } | { type: 'sendCookie' }
| { type: 'download', url: string } | { type: 'download', url: string }
| { type: 'subscribe', url: string } | { type: 'subscribe', url: string }
| { type: 'unsubscribe', url: string }
| { type: 'videoExists', id: string }
| { type: 'getChannel', url: string }
*/ */
function handleMessage(request, sender, sendResponse) { function handleMessage(request, sender, sendResponse) {
console.log('message background.js listener got message', request); console.log('message background.js listener got message', request);
@ -209,7 +269,17 @@ function handleMessage(request, sender, sendResponse) {
return await download(request.url); return await download(request.url);
} }
case 'subscribe': { case 'subscribe': {
return await subscribe(request.url); return await subscribe(request.url, true);
}
case 'unsubscribe': {
let channelId = await getChannelId(request.url);
return await subscribe(channelId, false);
}
case 'videoExists': {
return await videoExists(request.videoId);
}
case 'getChannel': {
return await getChannel(request.channelHandle);
} }
default: { default: {
let err = new Error(`unknown message type ${JSON.stringify(request.type)}`); let err = new Error(`unknown message type ${JSON.stringify(request.type)}`);

View File

@ -14,7 +14,7 @@
<a href="#" id="ta-url" target="_blank"> <a href="#" id="ta-url" target="_blank">
<img src="/images/logo.png" alt="ta-logo"> <img src="/images/logo.png" alt="ta-logo">
</a> </a>
<span>v0.1.3</span> <span>v0.3.1</span>
</div> </div>
<hr> <hr>
<form class="login-form"> <form class="login-form">
@ -30,8 +30,14 @@
<hr> <hr>
<p>Options:</p> <p>Options:</p>
<div class="options"> <div class="options">
<input type="checkbox" id="sendCookies" name="sendCookies"> <div>
<span>Sync YouTube cookies</span><span id="sendCookiesStatus"></span> <input type="checkbox" id="sendCookies" name="sendCookies">
<span>Sync YouTube cookies</span><span id="sendCookiesStatus"></span>
</div>
<div>
<input type="checkbox" id="autostart" name="autostart">
<span>Autostart Downloads</span>
</div>
</div> </div>
<hr> <hr>
<div class="icons"> <div class="icons">

View File

@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "TubeArchivist Companion", "name": "TubeArchivist Companion",
"description": "Interact with your selfhosted TA server.", "description": "Interact with your selfhosted TA server.",
"version": "0.1.3", "version": "0.3.1",
"icons": { "icons": {
"48": "/images/icon.png", "48": "/images/icon.png",
"128": "/images/icon128.png" "128": "/images/icon128.png"

View File

@ -2,7 +2,7 @@
"manifest_version": 2, "manifest_version": 2,
"name": "TubeArchivist Companion", "name": "TubeArchivist Companion",
"description": "Interact with your selfhosted TA server.", "description": "Interact with your selfhosted TA server.",
"version": "0.1.3", "version": "0.3.1",
"icons": { "icons": {
"128": "/images/icon128.png" "128": "/images/icon128.png"
}, },

View File

@ -38,6 +38,11 @@ function clearError() {
errorOut.style.display = 'none'; errorOut.style.display = 'none';
} }
function clearTempLocalStorage() {
browserType.storage.local.remove('popupApiKey');
browserType.storage.local.remove('popupFullUrl');
}
// store access details // store access details
document.getElementById('save-login').addEventListener('click', function () { document.getElementById('save-login').addEventListener('click', function () {
let url = document.getElementById('full-url').value; let url = document.getElementById('full-url').value;
@ -73,6 +78,25 @@ document.getElementById('sendCookies').addEventListener('click', function () {
sendCookie(); sendCookie();
}); });
// autostart
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() { function sendCookie() {
console.log('popup send cookie'); console.log('popup send cookie');
clearError(); clearError();
@ -104,9 +128,22 @@ function sendCookie() {
sending.then(handleResponse, handleError); sending.then(handleResponse, handleError);
} }
function toggleAutostart() {
let checked = document.getElementById('autostart').checked;
let toStore = {
autostart: {
checked: checked,
},
};
browserType.storage.local.set(toStore, function () {
console.log('stored option: ' + JSON.stringify(toStore));
});
}
// send ping message to TA backend // send ping message to TA backend
function pingBackend() { function pingBackend() {
clearError(); clearError();
clearTempLocalStorage();
function handleResponse() { function handleResponse() {
console.log('connection validated'); console.log('connection validated');
setStatusIcon(true); setStatusIcon(true);
@ -167,6 +204,12 @@ document.addEventListener('DOMContentLoaded', async () => {
function onGot(item) { function onGot(item) {
if (!item.access) { if (!item.access) {
console.log('no access details found'); 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); setStatusIcon(false);
return; return;
} }
@ -190,11 +233,25 @@ document.addEventListener('DOMContentLoaded', async () => {
setCookieState(); setCookieState();
} }
browserType.storage.local.get('access', function (result) { function setAutostartOption(result) {
console.log(result);
if (!result.autostart || result.autostart.checked === false) {
console.log('autostart not set');
return;
}
console.log('set options: ' + JSON.stringify(result));
document.getElementById('autostart').checked = true;
}
browserType.storage.local.get(['access', 'popupFullUrl', 'popupApiKey'], function (result) {
onGot(result); onGot(result);
}); });
browserType.storage.local.get('sendCookies', function (result) { browserType.storage.local.get('sendCookies', function (result) {
setCookiesOptions(result); setCookiesOptions(result);
}); });
browserType.storage.local.get('autostart', function (result) {
setAutostartOption(result);
});
}); });

View File

@ -4,32 +4,6 @@ content script running on youtube.com
'use strict'; 'use strict';
let browserType = getBrowser();
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
console.log('detected firefox');
return browser;
} else {
console.log('detected chrome');
return chrome;
}
} else {
console.log('failed to dedect browser');
throw 'browser detection error';
}
}
async function sendMessage(message) {
let { success, value } = await browserType.runtime.sendMessage(message);
if (!success) {
throw value;
}
return value;
}
const downloadIcon = `<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" const downloadIcon = `<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve"> viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
@ -111,193 +85,41 @@ viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="pres
</g> </g>
</svg>`; </svg>`;
function buildButtonDiv() { const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>minus-thick</title><path d="M20 14H4V10H20" /></svg>`;
let buttonDiv = document.createElement('div');
buttonDiv.setAttribute('id', 'ta-channel-button');
Object.assign(buttonDiv.style, { let browserType = getBrowser();
display: 'flex',
alignItems: 'center',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '14px',
padding: '5px',
margin: '5px',
borderRadius: '8px',
});
return buttonDiv;
}
function buildSubLink(channelContainer) { // boilerplate to dedect browser type api
let subLink = document.createElement('span'); function getBrowser() {
subLink.innerText = 'Subscribe'; if (typeof chrome !== 'undefined') {
subLink.addEventListener('click', e => { if (typeof browser !== 'undefined') {
e.preventDefault(); console.log('detected firefox');
let currentLocation = window.location.href; return browser;
console.log('subscribe to: ' + currentLocation);
sendUrl(currentLocation, 'subscribe', subLink);
});
subLink.addEventListener('mouseover', e => {
let subText;
if (window.location.pathname === '/watch') {
let currentLocation = window.location.href;
subText = currentLocation;
} else { } else {
subText = channelContainer.querySelector('#text').textContent; console.log('detected chrome');
return chrome;
} }
} else {
e.target.title = 'TA Subscribe: ' + subText; console.log('failed to dedect browser');
}); throw 'browser detection error';
Object.assign(subLink.style, { }
padding: '5px',
cursor: 'pointer',
});
return subLink;
}
function buildSpacer() {
let spacer = document.createElement('span');
spacer.innerText = '|';
return spacer;
}
function buildDlLink(channelContainer) {
let dlLink = document.createElement('span');
dlLink.innerHTML = downloadIcon;
dlLink.addEventListener('click', e => {
e.preventDefault();
let currentLocation = window.location.href;
console.log('download: ' + currentLocation);
sendUrl(currentLocation, 'download', dlLink);
});
dlLink.addEventListener('mouseover', e => {
let subText;
let currentLocation = window.location.href;
if (window.location.pathname === '/watch') {
subText = currentLocation;
} else {
subText = channelContainer.querySelector('#text').textContent + ' ' + currentLocation;
}
e.target.title = 'TA Download: ' + subText;
});
Object.assign(dlLink.style, {
filter: 'invert()',
width: '20px',
padding: '0 5px',
cursor: 'pointer',
});
return dlLink;
}
function buildChannelButton(channelContainer) {
let buttonDiv = buildButtonDiv();
let subLink = buildSubLink(channelContainer);
buttonDiv.appendChild(subLink);
let spacer = buildSpacer();
buttonDiv.appendChild(spacer);
let dlLink = buildDlLink(channelContainer);
buttonDiv.appendChild(dlLink);
return buttonDiv;
} }
function getChannelContainers() { function getChannelContainers() {
let nodes = document.querySelectorAll('#inner-header-container, #owner'); const elements = document.querySelectorAll('.yt-flexible-actions-view-model-wiz, #owner');
return nodes; const channelContainerNodes = [];
elements.forEach(element => {
if (isElementVisible(element)) {
channelContainerNodes.push(element);
}
});
return channelContainerNodes;
} }
function getThubnailContainers() { function isElementVisible(element) {
let nodes = document.querySelectorAll('#thumbnail'); return element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
return nodes;
}
function buildVideoButton(thumbContainer) {
let thumbLink = thumbContainer?.href;
if (!thumbLink) return;
if (thumbLink.includes('list=')) return;
let ggp = thumbContainer?.parentElement?.parentElement;
if (ggp?.id !== 'dismissible') return;
let dlButton = document.createElement('a');
dlButton.setAttribute('id', 'ta-video-button');
dlButton.href = '#';
dlButton.addEventListener('click', e => {
e.preventDefault();
let videoLink = thumbContainer.href;
console.log('download: ' + videoLink);
sendUrl(videoLink, 'download', dlButton);
});
ggp.addEventListener('mouseover', () => {
Object.assign(dlButton.style, {
opacity: 1,
});
let videoTitle = thumbContainer.href;
dlButton.title = 'TA download: ' + videoTitle;
});
ggp.addEventListener('mouseout', () => {
Object.assign(dlButton.style, {
opacity: 0,
});
});
Object.assign(dlButton.style, {
display: 'flex',
position: 'absolute',
top: '5px',
left: '5px',
alignItems: 'center',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '1.4rem',
textDecoration: 'none',
borderRadius: '8px',
cursor: 'pointer',
opacity: 0,
});
let dlIcon = document.createElement('span');
dlIcon.innerHTML = downloadIcon;
Object.assign(dlIcon.style, {
filter: 'invert()',
width: '18px',
padding: '7px 8px',
});
dlButton.appendChild(dlIcon);
return dlButton;
}
// 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;
} }
function ensureTALinks() { function ensureTALinks() {
@ -311,16 +133,364 @@ function ensureTALinks() {
channelContainer.hasTA = true; channelContainer.hasTA = true;
} }
let thumbContainerNodes = getThubnailContainers(); let titleContainerNodes = getTitleContainers();
for (let titleContainer of titleContainerNodes) {
for (let thumbContainer of thumbContainerNodes) { let parent = getNearestH3(titleContainer);
if (thumbContainer.hasTA) continue; if (!parent) continue;
let videoButton = buildVideoButton(thumbContainer); if (parent.hasTA) continue;
let videoButton = buildVideoButton(titleContainer);
if (videoButton == null) continue; if (videoButton == null) continue;
thumbContainer.parentElement.appendChild(videoButton); processTitle(parent);
thumbContainer.hasTA = true; parent.appendChild(videoButton);
parent.hasTA = true;
} }
} }
ensureTALinks = throttled(ensureTALinks, 700);
function adjustOwner(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) {
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',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '14px',
padding: '5px',
'margin-left': '8px',
borderRadius: '18px',
});
return buttonDiv;
}
function buildChannelSubButton(channelHandle) {
let channelSubButton = document.createElement('span');
channelSubButton.innerText = 'Checking...';
channelSubButton.title = `TA Subscribe: ${channelHandle}`;
channelSubButton.setAttribute('data-id', channelHandle);
channelSubButton.setAttribute('data-type', 'channel');
channelSubButton.addEventListener('click', e => {
e.preventDefault();
if (channelSubButton.innerText === 'Subscribe') {
console.log(`subscribe to: ${channelHandle}`);
sendUrl(channelHandle, 'subscribe', channelSubButton);
} else if (channelSubButton.innerText === 'Unsubscribe') {
console.log(`unsubscribe from: ${channelHandle}`);
sendUrl(channelHandle, 'unsubscribe', channelSubButton);
} else {
console.log('Unknown state');
}
});
Object.assign(channelSubButton.style, {
padding: '5px',
cursor: 'pointer',
});
checkChannelSubscribed(channelSubButton);
return channelSubButton;
}
function checkChannelSubscribed(channelSubButton) {
function handleResponse(message) {
if (!message || (typeof message === 'object' && message.channel_subscribed === false)) {
channelSubButton.innerText = 'Subscribe';
} else if (typeof message === 'object' && message.channel_subscribed === true) {
channelSubButton.innerText = 'Unsubscribe';
} else {
console.log('Unknown state');
}
}
function handleError(e) {
buttonError(channelSubButton);
channelSubButton.innerText = 'Error';
console.error('error', e);
}
let channelHandle = channelSubButton.dataset.id;
let message = { type: 'getChannel', channelHandle };
let sending = sendMessage(message);
sending.then(handleResponse, handleError);
}
function buildSpacer() {
let spacer = document.createElement('span');
spacer.innerText = '|';
return spacer;
}
function buildChannelDownloadButton() {
let channelDownloadButton = document.createElement('span');
let currentLocation = window.location.href;
let urlObj = new URL(currentLocation);
if (urlObj.pathname.startsWith('/watch')) {
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}`;
checkVideoExists(channelDownloadButton);
} else {
channelDownloadButton.setAttribute('data-id', currentLocation);
channelDownloadButton.setAttribute('data-type', 'channel');
channelDownloadButton.title = `TA download channel ${currentLocation}`;
}
channelDownloadButton.innerHTML = downloadIcon;
channelDownloadButton.addEventListener('click', e => {
e.preventDefault();
console.log(`download: ${currentLocation}`);
sendDownload(channelDownloadButton);
});
Object.assign(channelDownloadButton.style, {
filter: 'invert()',
width: '20px',
padding: '0 5px',
cursor: 'pointer',
});
return channelDownloadButton;
}
function getTitleContainers() {
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 videoId = getVideoId(titleContainer);
if (!videoId) return;
const dlButton = document.createElement('a');
dlButton.classList.add('ta-button');
dlButton.href = '#';
Object.assign(dlButton.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '1.4rem',
textDecoration: 'none',
borderRadius: '8px',
cursor: 'pointer',
height: 'fit-content',
opacity: 0,
});
let dlIcon = document.createElement('span');
dlIcon.innerHTML = defaultIcon;
Object.assign(dlIcon.style, {
filter: 'invert()',
width: '15px',
height: '15px',
padding: '7px 8px',
});
dlButton.appendChild(dlIcon);
dlButton.addEventListener('click', e => {
e.preventDefault();
sendDownload(dlButton);
e.stopPropagation();
});
return dlButton;
}
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');
}
element = element.parentNode;
}
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;
Object.assign(titleContainer.style, {
display: 'flex',
gap: '15px',
});
titleContainer.classList.add('title-container');
titleContainer.addEventListener('mouseenter', () => {
const taButton = titleContainer.querySelector('.ta-button');
if (!taButton) return;
if (!taButton.isChecked) checkVideoExists(taButton);
taButton.style.opacity = 1;
});
titleContainer.addEventListener('mouseleave', () => {
const taButton = titleContainer.querySelector('.ta-button');
if (!taButton) return;
taButton.style.opacity = 0;
});
titleContainer.hasListener = true;
}
function checkVideoExists(taButton) {
function handleResponse(message) {
let buttonSpan = taButton.querySelector('span') || taButton;
if (message !== false) {
buttonSpan.innerHTML = checkmarkIcon;
buttonSpan.title = 'Open in TA';
buttonSpan.addEventListener('click', () => {
let win = window.open(message, '_blank');
win.focus();
});
} else {
buttonSpan.innerHTML = downloadIcon;
}
taButton.isChecked = true;
}
function handleError(e) {
buttonError(taButton);
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);
}
function sendDownload(button) {
let url = button.dataset.id;
if (!url) return;
sendUrl(url, 'download', button);
}
function buttonError(button) { function buttonError(button) {
let buttonSpan = button.querySelector('span'); let buttonSpan = button.querySelector('span');
@ -347,13 +517,10 @@ function buttonSuccess(button) {
if (buttonSpan.innerHTML === 'Subscribe') { if (buttonSpan.innerHTML === 'Subscribe') {
buttonSpan.innerHTML = 'Success'; buttonSpan.innerHTML = 'Success';
setTimeout(() => { setTimeout(() => {
buttonSpan.innerHTML = 'Subscribe'; buttonSpan.innerHTML = 'Unsubscribe';
}, 2000); }, 2000);
} else { } else {
buttonSpan.innerHTML = checkmarkIcon; buttonSpan.innerHTML = checkmarkIcon;
setTimeout(() => {
buttonSpan.innerHTML = downloadIcon;
}, 2000);
} }
} }
@ -367,9 +534,8 @@ function sendUrl(url, action, button) {
} }
} }
function handleError(error) { function handleError(e) {
console.log('error'); console.log('error', e);
console.log(JSON.stringify(error));
buttonError(button); buttonError(button);
} }
@ -381,19 +547,50 @@ function sendUrl(url, action, button) {
sending.then(handleResponse, handleError); sending.then(handleResponse, handleError);
} }
let throttleBlock; async function sendMessage(message) {
const throttle = (callback, time) => { let { success, value } = await browserType.runtime.sendMessage(message);
if (throttleBlock) return; if (!success) {
throttleBlock = true; throw value;
setTimeout(() => { }
callback(); return value;
throttleBlock = false; }
}, time);
}; function cleanButtons() {
console.log('trigger clean buttons');
document.querySelectorAll('.ta-button').forEach(button => {
button.parentElement.hasTA = false;
button.remove();
});
document.querySelectorAll('.ta-channel-button').forEach(button => {
button.parentElement.hasTA = false;
button.remove();
});
}
let oldHref = document.location.href;
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 => { let observer = new MutationObserver(list => {
const currentHref = document.location.href;
if (currentHref !== oldHref) {
cleanButtons();
oldHref = currentHref;
}
if (list.some(i => i.type === 'childList' && i.addedNodes.length > 0)) { if (list.some(i => i.type === 'childList' && i.addedNodes.length > 0)) {
throttle(ensureTALinks, 700); ensureTALinks();
} }
}); });

View File

@ -63,7 +63,7 @@ hr {
color: #00202f; color: #00202f;
} }
.options { .options {
display: flex; display: block;
padding-bottom: 10px; padding-bottom: 10px;
} }
.options span { .options span {