Compare commits

...

62 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
simon 144dda3a84
bump version 2023-03-02 18:06:24 +07:00
simon 2cc5cfc17d
finetune button positioning and margin 2023-03-02 17:58:56 +07:00
Kevin Gibbons 9528a347e0
Refactor message passing and surface errors to users (#18)
* refactor message passing between popup/background

* surface errors to user in popup.js

* move logic into background.js

* split youtube message

* handle errors from URL constructor
2023-02-20 10:24:27 +07:00
simon c7069d90cb
print last tags before deploy 2023-01-14 09:29:29 +07:00
16 changed files with 724 additions and 315 deletions

View File

@ -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',
},
};

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.
- 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.
- 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.
## 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
@ -51,9 +51,13 @@ A green checkmark will appear next to the *Save* button if your connection is wo
## Options
- **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
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*
@ -72,12 +76,16 @@ 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:
- [ ] 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 button for videos on playlist
- [ ] Error handling for connection errors
- [ ] 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
- [X] Error handling for connection errors
- [X] Dynamically inject buttons with mutation observer
## 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

BIN
assets/tac-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -8,6 +8,9 @@ if [[ $(basename "$(pwd)") != 'tubearchivist_browserextension' ]]; then
exit 1
fi
echo "latest tags:"
git tag | tail -n 5 | sort -r
printf "\ncreate new version:\n"
read -r VERSION

View File

@ -84,44 +84,104 @@ async function getCookieState() {
return response;
}
// send ping to server, return response
// send ping to server
async function verifyConnection() {
const path = 'api/ping/';
let response = await sendGet(path);
console.log('verify connection: ' + JSON.stringify(response));
let message = await sendGet(path);
console.log('verify connection: ' + JSON.stringify(message));
return response;
if (message?.response === 'pong') {
return true;
} else if (message?.detail) {
throw new Error(message.detail);
} else {
throw new Error(`got unknown message ${JSON.stringify(message)}`);
}
}
// send youtube link from injected buttons
async function youtubeLink(youtubeMessage) {
let path;
let payload;
if (youtubeMessage.action === 'download') {
path = 'api/download/';
payload = {
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(
apiURL,
{
data: [
{
youtube_id: youtubeMessage.url,
youtube_id: url,
status: 'pending',
},
],
};
} else if (youtubeMessage.action === 'subscribe') {
path = 'api/channel/';
payload = {
},
'POST'
);
}
async function subscribe(url, subscribed) {
return await sendData(
'api/channel/',
{
data: [
{
channel_id: youtubeMessage.url,
channel_subscribed: true,
channel_id: url,
channel_subscribed: subscribed,
},
],
};
},
'POST'
);
}
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 response = await sendData(path, payload, 'POST');
return response;
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) {
@ -148,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 = [
@ -163,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));
}
}
}
@ -172,33 +235,69 @@ async function sendCookies() {
return response;
}
// process and return message if needed
/*
process and return message if needed
the following messages are supported:
type Message =
| { type: 'verify' }
| { type: 'cookieState' }
| { type: 'sendCookie' }
| { type: 'download', url: string }
| { type: 'subscribe', url: string }
| { type: 'unsubscribe', url: string }
| { type: 'videoExists', id: string }
| { type: 'getChannel', url: string }
*/
function handleMessage(request, sender, sendResponse) {
console.log('message background.js listener: ' + JSON.stringify(request));
console.log('message background.js listener got message', request);
if (request.verify === true) {
let response = verifyConnection();
response.then(message => {
sendResponse(message);
// this function must return the value `true` in chrome to signal the response will be async;
// it cannot return a promise
// so in order to use async/await, we need a wrapper
(async () => {
switch (request.type) {
case 'verify': {
return await verifyConnection();
}
case 'cookieState': {
return await getCookieState();
}
case 'sendCookie': {
return await sendCookies();
}
case 'download': {
return await download(request.url);
}
case 'subscribe': {
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: {
let err = new Error(`unknown message type ${JSON.stringify(request.type)}`);
console.log(err);
throw err;
}
}
})()
.then(value => sendResponse({ success: true, value }))
.catch(e => {
console.error(e);
let message = e?.message ?? e;
if (message === 'Failed to fetch') {
// chrome's error message for failed `fetch` is not very user-friendly
message = 'Could not connect to server';
}
sendResponse({ success: false, value: message });
});
} else if (request.youtube) {
let response = youtubeLink(request.youtube);
response.then(message => {
sendResponse(message);
});
} else if (request.cookieState) {
let response = getCookieState();
response.then(message => {
sendResponse(message);
});
} else if (request.sendCookie) {
console.log('backgound: ' + JSON.stringify(request));
let response = sendCookies();
response.then(message => {
sendResponse(message);
});
}
return true;
}

View File

@ -14,7 +14,7 @@
<a href="#" id="ta-url" target="_blank">
<img src="/images/logo.png" alt="ta-logo">
</a>
<span>v0.1.2</span>
<span>v0.3.1</span>
</div>
<hr>
<form class="login-form">
@ -26,11 +26,18 @@
<div class="submit">
<button id="save-login">Save</button><span id="status-icon">&#9744;</span>
</div>
<div id="error-out"></div>
<hr>
<p>Options:</p>
<div class="options">
<input type="checkbox" id="sendCookies" name="sendCookies">
<span>Sync YouTube cookies</span><span id="sendCookiesStatus"></span>
<div>
<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>
<hr>
<div class="icons">

View File

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

View File

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

View File

@ -15,29 +15,57 @@ function getBrowser() {
return chrome;
}
} else {
console.log('failed to dedect browser');
console.log('failed to detect browser');
throw 'browser detection error';
}
}
async function sendMessage(message) {
let { success, value } = await browserType.runtime.sendMessage(message);
if (!success) {
throw value;
}
return value;
}
let errorOut = document.getElementById('error-out');
function setError(message) {
errorOut.style.display = 'initial';
errorOut.innerText = message;
}
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;
if (!url.includes('://')) {
url = 'http://' + url;
}
let parsed = new URL(url);
let toStore = {
access: {
url: `${parsed.protocol}//${parsed.hostname}`,
port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80'),
apiKey: document.getElementById('api-key').value,
},
};
browserType.storage.local.set(toStore, function () {
console.log('Stored connection details: ' + JSON.stringify(toStore));
pingBackend();
});
try {
clearError();
let parsed = new URL(url);
let toStore = {
access: {
url: `${parsed.protocol}//${parsed.hostname}`,
port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80'),
apiKey: document.getElementById('api-key').value,
},
};
browserType.storage.local.set(toStore, function () {
console.log('Stored connection details: ' + JSON.stringify(toStore));
pingBackend();
});
} catch (e) {
setError(e.message);
}
});
// verify connection status
@ -50,8 +78,28 @@ document.getElementById('sendCookies').addEventListener('click', function () {
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() {
console.log('popup send cookie');
clearError();
function handleResponse(message) {
console.log('handle cookie response: ' + JSON.stringify(message));
@ -61,6 +109,7 @@ function sendCookie() {
function handleError(error) {
console.log(`Error: ${error}`);
setError(error);
}
let checked = document.getElementById('sendCookies').checked;
@ -75,26 +124,39 @@ function sendCookie() {
if (checked === false) {
return;
}
let sending = browserType.runtime.sendMessage({ sendCookie: true });
let sending = sendMessage({ type: 'sendCookie' });
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
function pingBackend() {
function handleResponse(message) {
if (message.response === 'pong') {
setStatusIcon(true);
console.log('connection validated');
}
clearError();
clearTempLocalStorage();
function handleResponse() {
console.log('connection validated');
setStatusIcon(true);
}
function handleError(error) {
console.log(`Error: ${error}`);
console.log(`Verify got error: ${error}`);
setStatusIcon(false);
setError(error);
}
console.log('ping TA server');
let sending = browserType.runtime.sendMessage({ verify: true });
let sending = sendMessage({ type: 'verify' });
sending.then(handleResponse, handleError);
}
@ -105,6 +167,7 @@ function addUrl(access) {
}
function setCookieState() {
clearError();
function handleResponse(message) {
console.log(message);
document.getElementById('sendCookies').checked = message.cookie_enabled;
@ -115,10 +178,11 @@ function setCookieState() {
function handleError(error) {
console.log(`Error: ${error}`);
setError(error);
}
console.log('set cookie state');
let sending = browserType.runtime.sendMessage({ cookieState: true });
let sending = sendMessage({ type: 'cookieState' });
sending.then(handleResponse, handleError);
document.getElementById('sendCookies').checked = true;
}
@ -140,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;
}
@ -163,11 +233,25 @@ document.addEventListener('DOMContentLoaded', async () => {
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);
});
browserType.storage.local.get('sendCookies', function (result) {
setCookiesOptions(result);
});
browserType.storage.local.get('autostart', function (result) {
setAutostartOption(result);
});
});

View File

@ -4,24 +4,6 @@ content script running on youtube.com
'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';
}
}
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">
<style type="text/css">
@ -103,193 +85,41 @@ viewBox="0 0 500 500" style="enable-background:new 0 0 500 500;" xml:space="pres
</g>
</svg>`;
function buildButtonDiv() {
let buttonDiv = document.createElement('div');
buttonDiv.setAttribute('id', 'ta-channel-button');
const defaultIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>minus-thick</title><path d="M20 14H4V10H20" /></svg>`;
Object.assign(buttonDiv.style, {
display: 'flex',
alignItems: 'center',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '14px',
padding: '5px',
margin: '0 5px',
borderRadius: '8px',
});
return buttonDiv;
}
let browserType = getBrowser();
function buildSubLink(channelContainer) {
let subLink = document.createElement('span');
subLink.innerText = 'Subscribe';
subLink.addEventListener('click', e => {
e.preventDefault();
let currentLocation = window.location.href;
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;
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
console.log('detected firefox');
return browser;
} else {
subText = channelContainer.querySelector('#text').textContent;
console.log('detected chrome');
return chrome;
}
e.target.title = 'TA Subscribe: ' + subText;
});
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;
} else {
console.log('failed to dedect browser');
throw 'browser detection error';
}
}
function getChannelContainers() {
let nodes = document.querySelectorAll('#inner-header-container, #owner');
return nodes;
const elements = document.querySelectorAll('.yt-flexible-actions-view-model-wiz, #owner');
const channelContainerNodes = [];
elements.forEach(element => {
if (isElementVisible(element)) {
channelContainerNodes.push(element);
}
});
return channelContainerNodes;
}
function getThubnailContainers() {
let nodes = document.querySelectorAll('#thumbnail');
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: '20px',
padding: '10px 13px',
});
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 isElementVisible(element) {
return element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
}
function ensureTALinks() {
@ -303,16 +133,364 @@ function ensureTALinks() {
channelContainer.hasTA = true;
}
let thumbContainerNodes = getThubnailContainers();
for (let thumbContainer of thumbContainerNodes) {
if (thumbContainer.hasTA) continue;
let videoButton = buildVideoButton(thumbContainer);
let titleContainerNodes = getTitleContainers();
for (let titleContainer of titleContainerNodes) {
let parent = getNearestH3(titleContainer);
if (!parent) continue;
if (parent.hasTA) continue;
let videoButton = buildVideoButton(titleContainer);
if (videoButton == null) continue;
thumbContainer.parentElement.appendChild(videoButton);
thumbContainer.hasTA = true;
processTitle(parent);
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) {
let buttonSpan = button.querySelector('span');
@ -339,13 +517,10 @@ function buttonSuccess(button) {
if (buttonSpan.innerHTML === 'Subscribe') {
buttonSpan.innerHTML = 'Success';
setTimeout(() => {
buttonSpan.innerHTML = 'Subscribe';
buttonSpan.innerHTML = 'Unsubscribe';
}, 2000);
} else {
buttonSpan.innerHTML = checkmarkIcon;
setTimeout(() => {
buttonSpan.innerHTML = downloadIcon;
}, 2000);
}
}
@ -359,37 +534,63 @@ 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);
}
let payload = {
youtube: {
url: url,
action: action,
},
};
let message = { type: action, url };
console.log('youtube link: ' + JSON.stringify(payload));
console.log('youtube link: ' + JSON.stringify(message));
let sending = browserType.runtime.sendMessage(payload);
let sending = sendMessage(message);
sending.then(handleResponse, handleError);
}
let throttleBlock;
const throttle = (callback, time) => {
if (throttleBlock) return;
throttleBlock = true;
setTimeout(() => {
callback();
throttleBlock = false;
}, time);
};
async function sendMessage(message) {
let { success, value } = await browserType.runtime.sendMessage(message);
if (!success) {
throw value;
}
return value;
}
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 => {
const currentHref = document.location.href;
if (currentHref !== oldHref) {
cleanButtons();
oldHref = currentHref;
}
if (list.some(i => i.type === 'childList' && i.addedNodes.length > 0)) {
throttle(ensureTALinks, 700);
ensureTALinks();
}
});

View File

@ -63,7 +63,7 @@ hr {
color: #00202f;
}
.options {
display: flex;
display: block;
padding-bottom: 10px;
}
.options span {
@ -76,4 +76,8 @@ hr {
}
.icons img {
width: 25px;
}
}
#error-out {
color: red;
display: none; /* will be made visible when an error occurs */
}