Compare commits

..

No commits in common. "master" and "v0.0.3" have entirely different histories.

29 changed files with 494 additions and 3120 deletions

View File

@ -1,24 +0,0 @@
'use strict';
module.exports = {
extends: ['eslint:recommended', 'eslint-config-prettier'],
parserOptions: {
ecmaVersion: 2020,
},
env: {
browser: true,
es6: true,
},
globals: {
browser: 'readonly',
chrome: 'readonly',
},
rules: {
strict: ['error', 'global'],
'no-unused-vars': ['error', { vars: 'local' }],
eqeqeq: ['error', 'always', { null: 'ignore' }],
curly: ['error', 'multi-line'],
'no-var': 'error',
'no-func-assign': 'off',
'no-inner-declarations': 'off',
},
};

View File

@ -1,16 +0,0 @@
name: lint_js
on: [pull_request, push]
jobs:
check:
name: lint_js
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: '16'
- run: npm ci
- run: npm run lint
- run: npm run format -- --check

3
.gitignore vendored
View File

@ -3,6 +3,3 @@ extension/manifest.json
# release builds
release/*
# JavaScript stuff
node_modules

View File

@ -1,35 +1,19 @@
![Tube Archivist Companion](assets/tube-archivist-companion-banner.png?raw=true "Tube Archivist Companion Banner")
<h1 align="center">Browser Extension for Tube Archivist</h1>
<h1 align="center">Tube Archivist Companion for your Browser</h1>
<div align="center">
<a href="https://addons.mozilla.org/addon/tubearchivist-companion/" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-firefox.png" alt="tubearchivist-firefox" title="TA Companion Firefox users" height="50" width="190"/></a>
<a href="https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-chrome.png" alt="tubearchivist-chrome" title="TA Companion Chrome users" height="50" width="190"/></a>
<a href="https://www.tilefy.me" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-firefox.png" alt="tubearchivist-firefox" title="TA Companion Firefox users" height="50" width="190"/></a>
<a href="https://www.tilefy.me" target="_blank"><img src="https://tiles.tilefy.me/t/tubearchivist-chrome.png" alt="tubearchivist-chrome" title="TA Companion Chrome users" height="50" width="190"/></a>
</div>
## Core Functionality
This is a browser extension to bridge YouTube with [Tube Archivist](https://github.com/tubearchivist/tubearchivist), your self hosted YouTube media server.
- 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 title to reveal a download button for that video.
A browser extension to bridge YouTube with [Tube Archivist](https://github.com/tubearchivist/tubearchivist). This extension allows you to do the following:
- Add your Tube Archivist connection details in the addon popup
- Add a download button to the popup for YouTube links
- Add a subscribe button to subscribe to channels and playlists
- Sync your cookies for yt-dlp.
## Screenshots
![popup screenshot](assets/tac-screenshot.png?raw=true "Tube Archivist Companion Popup")
Popup to enter your connection details.
<br><br>
![video page](assets/screenshot-video.png?raw=true "Tube Archivist Companion Video Page")
Button injected on video page to download the video or subscribe to the channel.
<br><br>
![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/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>
![popup screenshot](assets/screenshot.png?raw=true "Tube Archivist Companion Popup")
## Install
- Firefox: The addon is available on the [Extension store](https://addons.mozilla.org/addon/tubearchivist-companion/).
@ -39,53 +23,35 @@ Channel button injected to subscribe or download whole channel, video download b
After a new release here on GitHub, you'll get updates automatically in your browser. Due to the verification process, for Firefox this usually takes 1-2 hours, for Chrome 2-3 days.
## Permissions
- **Access your data for www.youtube.com**: Needed to inject download and subscribe buttons directly into the page.
- **Storage**: Needed to store your connection details.
- **Access your data for www.youtube.com**: Needed for the addon to know your current page on YouTube to send the link to Tube Archivist.
- **Storage**: Needed to store your connection details, needed to store your last visited YouTube link within the browser.
- **Cookie**: Needed to read your cookies for youtube.com to access restricted videos.
## Setup
- **URL**: This is where your Tube Archivist instance is located. Can be a host name or an IP address. Add the port if needed at the end, e.g. `:8000`.
- **API key**: You can find your API key on the settings page (Settings -> Application -> Integrations section -> API token) of your Tube Archivist instance.
- **URL**: This is where your Tube Archivist instance is located. Can be a host name or a IP address, use a full URL with protocol, e.g. *http://*.
- **Port**: Network port of TA.
- **API key**: You can find your API key on the settings page of your Tube Archivist instance.
A green checkmark will appear next to the *Save* button if your connection is working.
## Options
- **Sync YouTube cookies**: Send your cookies to TubeArchivist to use for yt-dlp requests.
- **Autostart**: Autostart and prioritize videos send from this extension.
- **Sync YouTube cookies**: Send your cookies to TubeArchivist to use for yt-dlp requests.
## Test this extension
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`.
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`.
- Firefox
- Open `about:debugging#/runtime/this-firefox`
- Click on *Load Temporary Add-on*
- Select the *manifest.json* file to load the addon.
- You can *inspect* background.js by lunching the debug tools from there.
- Select the *manifest.json* file to load the addon.
- Chrome / Chromium
- Open `chrome://extensions/`
- Toggle *Developer mode* on top right
- Click on *Load unpacked*
- Open the folder containing the *manifest.json* file.
- Click on *Service Worker* to open the dev tools at background.js.
## Compatibility
- Verify that you are running the [latest version](https://github.com/tubearchivist/tubearchivist/releases/latest) of Tube Archivist as the API is under development and will change.
- For testing this extension between releases, use the *unstable* builds of Tube Archivist, only for your testing environment.
- Verify that you are running the latest version of Tube Archivist as the API is under development and will change.
- For testing this extension between releases, use the *unstable* builds of Tube Archivist, only for your tesing environment.
## 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
- [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.
## Help needed
Join us on [Discord](https://discord.gg/AFwz8nE7BK) and help us improve and extend this project.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

BIN
assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -8,9 +8,6 @@ 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

@ -2,303 +2,239 @@
extension background script listening for events
*/
'use strict';
console.log('running background.js');
console.log("running background.js");
let browserType = getBrowser();
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
return browser;
if (typeof chrome !== "undefined") {
if (typeof browser !== "undefined") {
return browser;
} else {
return chrome;
}
} else {
return chrome;
}
} else {
console.log('failed to detect browser');
throw 'browser detection error';
}
console.log("failed to dedect browser");
throw "browser detection error"
};
}
// send get request to API backend
async function sendGet(path) {
let access = await getAccess();
const url = `${access.url}:${access.port}/${path}`;
console.log('GET: ' + url);
const rawResponse = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Token ' + access.apiKey,
mode: 'no-cors',
},
});
let access = await getAccess();
const url = `${access.url}:${access.port}/${path}`;
console.log("GET: " + url);
const content = await rawResponse.json();
return content;
const rawResponse = await fetch(url, {
method: "GET",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Token " + access.apiKey,
"mode": "no-cors"
}
});
const content = await rawResponse.json();
return content;
}
// send post/put request to API backend
async function sendData(path, payload, method) {
let access = await getAccess();
const url = `${access.url}:${access.port}/${path}`;
console.log(`${method}: ${url}`);
console.log(`${method}: ${JSON.stringify(payload)}`);
try {
let access = await getAccess();
const url = `${access.url}:${access.port}/${path}`;
console.log(`${method}: ${url}`);
console.log(`${method}: ${JSON.stringify(payload)}`);
const rawResponse = await fetch(url, {
method: method,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Token ' + access.apiKey,
mode: 'no-cors',
},
body: JSON.stringify(payload),
method: method,
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Token " + access.apiKey,
"mode": "no-cors"
},
body: JSON.stringify(payload)
});
const content = await rawResponse.json();
return content;
} catch (e) {
console.error(e);
return null;
}
}
// read access details from storage.local
async function getAccess() {
let storage = await browserType.storage.local.get('access');
return storage.access;
var storage = await browserType.storage.local.get("access");
return storage.access
}
// check if cookie is valid
async function getCookieState() {
const path = 'api/cookie/';
let response = await sendGet(path);
console.log('cookie state: ' + JSON.stringify(response));
return response;
const path = "api/cookie/";
let response = await sendGet(path)
console.log("cookie state: " + JSON.stringify(response));
return response
}
// send ping to server
// send ping to server, return response
async function verifyConnection() {
const path = 'api/ping/';
let message = await sendGet(path);
console.log('verify connection: ' + JSON.stringify(message));
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)}`);
}
const path = "api/ping/";
let response = await sendGet(path)
console.log("verify connection: " + JSON.stringify(response));
return response
}
// send youtube link from injected buttons
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: url,
status: 'pending',
},
],
},
'POST'
);
// store last youtube link
function setYoutubeLink(data) {
browserType.storage.local.set(data, function() {
console.log("Stored history: " + JSON.stringify(data));
});
}
async function subscribe(url, subscribed) {
return await sendData(
'api/channel/',
{
data: [
{
channel_id: url,
channel_subscribed: subscribed,
},
],
},
'POST'
);
// send download task to server, return response
async function downloadLink(toDownload) {
const path = "api/download/";
let payload = {
"data": [
{
"youtube_id": toDownload,
"status": "pending",
}
]
}
let response = await sendData(path, payload, "POST")
return response
}
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 subscribeLink(toSubscribe) {
const path = "api/channel/";
let payload = {
"data": [
{
"channel_id": toSubscribe,
"channel_subscribed": true,
}
]
}
let response = await sendData(path, payload, "POST");
return response
}
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) {
const path = 'api/cookie/';
let payload = {
cookie: cookieLines.join('\n'),
};
let response = await sendData(path, payload, 'PUT');
return response;
const path = "api/cookie/";
let payload = {
"cookie": cookieLines.join("\n")
}
let response = await sendData(path, payload, "PUT");
return response
}
function buildCookieLine(cookie) {
return [
cookie.domain,
'TRUE',
cookie.path,
cookie.httpOnly.toString().toUpperCase(),
Math.trunc(cookie.expirationDate) || 0,
cookie.name,
cookie.value,
].join('\t');
return [
cookie.domain,
"TRUE",
cookie.path,
cookie.httpOnly.toString().toUpperCase(),
Math.trunc(cookie.expirationDate) || 0,
cookie.name,
cookie.value,
].join("\t");
}
async function sendCookies() {
console.log('function sendCookies');
const acceptableDomains = ['.youtube.com', 'youtube.com', 'www.youtube.com'];
console.log("function sendCookies");
let cookieStores = await browserType.cookies.getAllCookieStores();
let cookieLines = [
'# Netscape HTTP Cookie File',
'# https://curl.haxx.se/rfc/cookie_spec.html',
'# This is a generated file! Do not edit.\n',
];
for (let i = 0; i < cookieStores.length; i++) {
const cookieStore = cookieStores[i];
let allCookiesStore = await browserType.cookies.getAll({
domain: '.youtube.com',
storeId: cookieStore['id'],
});
for (let j = 0; j < allCookiesStore.length; j++) {
const cookie = allCookiesStore[j];
if (acceptableDomains.includes(cookie.domain)) {
cookieLines.push(buildCookieLine(cookie));
}
let cookieStores = await browserType.cookies.getAllCookieStores();
var cookieLines = [
"# Netscape HTTP Cookie File",
"# https://curl.haxx.se/rfc/cookie_spec.html",
"# This is a generated file! Do not edit.\n"
];
for (let i = 0; i < cookieStores.length; i++) {
const cookieStore = cookieStores[i];
var allCookiesStore = await browserType.cookies.getAll({
domain: ".youtube.com",
storeId: cookieStore["id"]
});
for (let j = 0; j < allCookiesStore.length; j++) {
const cookie = allCookiesStore[j];
cookieLines.push(buildCookieLine(cookie));
}
}
}
let response = cookieStr(cookieLines);
let response = cookieStr(cookieLines);
return response
return response;
}
/*
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 got message', request);
// 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;
}
// process and return message if needed
function handleMessage(request, sender, sendResponse) {
console.log("message background.js listener: " + JSON.stringify(request));
if (request.verify === true) {
let response = verifyConnection();
response.then(message => {
sendResponse(message);
})
} else if (request.youtube) {
setYoutubeLink(request)
} else if (request.download) {
let response = downloadLink(request.download.url);
response.then(message => {
sendResponse(message)
})
} else if (request.subscribe) {
let response = subscribeLink(request.subscribe.url);
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)
})
}
})()
.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 });
});
return true;
return true;
}
browserType.runtime.onMessage.addListener(handleMessage);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -14,37 +14,33 @@
<a href="#" id="ta-url" target="_blank">
<img src="/images/logo.png" alt="ta-logo">
</a>
<span>v0.3.2</span>
<span>v0.0.3</span>
</div>
<hr>
<div class="youtube-page" id="download"></div>
<form class="login-form">
<label for="full-url">Tube Archivist URL:</label>
<input type="text" id="full-url" name="url">
<label for="url">Tube Archivist IP:</label>
<input type="text" id="url" name="url">
<label for="port">Tube Archivist Port:</label>
<input type="text" id="port" name="port">
<label for="api-key">Tube Archivist API Key:</label>
<input type="password" id="api-key" name="api-key">
</form>
<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">
<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>
<input type="checkbox" id="sendCookies" name="sendCookies">
<span>Sync YouTube cookies</span><span id="sendCookiesStatus"></span>
</div>
<hr>
<div class="icons">
<div>
<a href="https://www.reddit.com/r/TubeArchivist/" target="_blank">
<img src="/images/social/reddit.svg" alt="reddit-icon"></a>
<a href="https://www.tubearchivist.com/discord" target="_blank">
<a href="https://discord.gg/AFwz8nE7BK" target="_blank">
<img src="/images/social/discord.svg" alt="discord-icon"></a>
<a href="https://github.com/tubearchivist/tubearchivist" target="_blank">
<img src="/images/social/github.svg" alt="github-icon">

View File

@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "TubeArchivist Companion",
"description": "Interact with your selfhosted TA server.",
"version": "0.3.2",
"version": "0.0.3",
"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.3.2",
"version": "0.0.3",
"icons": {
"128": "/images/icon128.png"
},

View File

@ -2,256 +2,280 @@
Loaded into popup index.html
*/
'use strict';
let browserType = getBrowser();
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
return browser;
if (typeof chrome !== "undefined") {
if (typeof browser !== "undefined") {
return browser;
} else {
return chrome;
}
} else {
return chrome;
}
} else {
console.log('failed to detect browser');
throw 'browser detection error';
}
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;
}
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;
}
try {
clearError();
let parsed = new URL(url);
document.getElementById("save-login").addEventListener("click", function () {
let toStore = {
access: {
url: `${parsed.protocol}//${parsed.hostname}`,
port: parsed.port || (parsed.protocol === 'https:' ? '443' : '80'),
apiKey: document.getElementById('api-key').value,
},
"access": {
"url": document.getElementById("url").value,
"port": document.getElementById("port").value,
"apiKey": document.getElementById("api-key").value
}
};
browserType.storage.local.set(toStore, function () {
console.log('Stored connection details: ' + JSON.stringify(toStore));
pingBackend();
browserType.storage.local.set(toStore, function() {
console.log("Stored connection details: " + JSON.stringify(toStore));
pingBackend();
});
} catch (e) {
setError(e.message);
}
});
})
// verify connection status
document.getElementById('status-icon').addEventListener('click', function () {
pingBackend();
});
document.getElementById("status-icon").addEventListener("click", function() {
pingBackend();
})
// send cookie
document.getElementById('sendCookies').addEventListener('click', function () {
sendCookie();
});
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();
console.log("popup send cookie");
function handleResponse(message) {
console.log('handle cookie response: ' + JSON.stringify(message));
let cookie_validated = message.cookie_validated;
document.getElementById('sendCookiesStatus').innerText = 'validated: ' + cookie_validated;
}
function handleResponse(message) {
console.log("handle cookie response: " + JSON.stringify(message));
let cookie_validated = message.cookie_validated;
document.getElementById("sendCookiesStatus").innerText = "validated: " + cookie_validated
}
function handleError(error) {
console.log(`Error: ${error}`);
setError(error);
}
function handleError(error) {
console.log(`Error: ${error}`);
}
let checked = document.getElementById('sendCookies').checked;
let toStore = {
sendCookies: {
checked: checked,
},
};
browserType.storage.local.set(toStore, function () {
console.log('stored option: ' + JSON.stringify(toStore));
});
if (checked === false) {
return;
}
let sending = sendMessage({ type: 'sendCookie' });
sending.then(handleResponse, handleError);
let checked = document.getElementById("sendCookies").checked;
let toStore = {
"sendCookies": {
"checked": checked
}
};
browserType.storage.local.set(toStore, function() {
console.log("stored option: " + JSON.stringify(toStore));
})
if (checked === false) {
return
}
let sending = browserType.runtime.sendMessage({"sendCookie": true});
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() {
clearError();
clearTempLocalStorage();
function handleResponse() {
console.log('connection validated');
setStatusIcon(true);
}
function handleError(error) {
console.log(`Verify got error: ${error}`);
setStatusIcon(false);
setError(error);
}
function handleResponse(message) {
if (message.response === "pong") {
setStatusIcon(true);
console.log("connection validated")
}
}
function handleError(error) {
console.log(`Error: ${error}`);
setStatusIcon(false);
}
console.log("ping TA server")
let sending = browserType.runtime.sendMessage({"verify": true});
sending.then(handleResponse, handleError);
console.log('ping TA server');
let sending = sendMessage({ type: 'verify' });
sending.then(handleResponse, handleError);
}
// add url to image
function addUrl(access) {
const url = `${access.url}:${access.port}`;
document.getElementById('ta-url').setAttribute('href', url);
const url = `${access.url}:${access.port}`;
document.getElementById("ta-url").setAttribute("href", url);
}
function setCookieState() {
clearError();
function handleResponse(message) {
console.log(message);
document.getElementById('sendCookies').checked = message.cookie_enabled;
if (message.validated_str) {
document.getElementById('sendCookiesStatus').innerText = message.validated_str;
function handleResponse(message) {
console.log(message);
document.getElementById("sendCookies").checked = message.cookie_enabled;
if (message.validated_str) {
document.getElementById("sendCookiesStatus").innerText = message.validated_str;
}
}
}
function handleError(error) {
console.log(`Error: ${error}`);
setError(error);
}
function handleError(error) {
console.log(`Error: ${error}`);
}
console.log('set cookie state');
let sending = sendMessage({ type: 'cookieState' });
sending.then(handleResponse, handleError);
document.getElementById('sendCookies').checked = true;
console.log("set cookie state");
let sending = browserType.runtime.sendMessage({"cookieState": true});
sending.then(handleResponse, handleError)
document.getElementById("sendCookies").checked = true;
}
// change status icon based on connection status
function setStatusIcon(connected) {
let statusIcon = document.getElementById('status-icon');
if (connected) {
statusIcon.innerHTML = '&#9745;';
statusIcon.style.color = 'green';
} else {
statusIcon.innerHTML = '&#9746;';
statusIcon.style.color = 'red';
}
let statusIcon = document.getElementById("status-icon")
if (connected == true) {
statusIcon.innerHTML = "&#9745;";
statusIcon.style.color = "green";
} else {
statusIcon.innerHTML = "&#9746;";
statusIcon.style.color = "red";
}
}
function downloadEvent() {
let button = document.getElementById("downloadButton");
let payload = {
"download": {
"url": button.getAttribute("data-id")
}
};
function handleResponse(message) {
console.log("popup.js response: " + JSON.stringify(message));
browserType.storage.local.remove("youtube").then(response => {
let download = document.getElementById("download");
download.innerHTML = ""
let message = document.createElement("p");
message.innerText = "Download link sent to Tube Archivist"
download.appendChild(message)
download.appendChild(document.createElement("hr"));
})
}
function handleError(error) {
console.log(`Error: ${error}`);
}
let sending = browserType.runtime.sendMessage(payload);
sending.then(handleResponse, handleError)
}
function subscribeEvent() {
let button = document.getElementById("subscribeButton");
let payload = {
"subscribe": {
"url": button.getAttribute("data-id")
}
};
function handleResponse(message) {
console.log("popup.js response: " + JSON.stringify(message));
browserType.storage.local.remove("youtube").then(response => {
let download = document.getElementById("download");
download.innerHTML = ""
let message = document.createElement("p");
message.innerText = "Subscribe link sent to Tube Archivist"
download.appendChild(message)
download.appendChild(document.createElement("hr"));
})
}
function handleError(error) {
console.log(`Error: ${error}`);
}
let sending = browserType.runtime.sendMessage(payload);
sending.then(handleResponse, handleError)
}
// fill in form
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;
document.addEventListener("DOMContentLoaded", async () => {
function onGot(item) {
if (!item.access) {
console.log("no access details found");
setStatusIcon(false);
return
}
document.getElementById("url").value = item.access.url;
document.getElementById("port").value = item.access.port;
document.getElementById("api-key").value = item.access.apiKey;
pingBackend();
addUrl(item.access);
};
function setCookiesOptions(result) {
if (!result.sendCookies || result.sendCookies.checked === false) {
console.log("sync cookies not set");
return
}
console.log("set options: " + JSON.stringify(result));
setCookieState();
}
let { url, port } = item.access;
let fullUrl = url;
if (!(url.startsWith('http://') && port === '80')) {
fullUrl += `:${port}`;
}
document.getElementById('full-url').value = fullUrl;
document.getElementById('api-key').value = item.access.apiKey;
pingBackend();
addUrl(item.access);
}
function onError(error) {
console.log(`Error: ${error}`);
};
function setCookiesOptions(result) {
if (!result.sendCookies || result.sendCookies.checked === false) {
console.log('sync cookies not set');
return;
}
console.log('set options: ' + JSON.stringify(result));
setCookieState();
}
browserType.storage.local.get("access", function(result) {
onGot(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("sendCookies", function(result) {
setCookiesOptions(result)
})
browserType.storage.local.get(['access', 'popupFullUrl', 'popupApiKey'], function (result) {
onGot(result);
});
browserType.storage.local.get("youtube", function(result) {
if (result.youtube) {
createButtons(result);
}
})
browserType.storage.local.get('sendCookies', function (result) {
setCookiesOptions(result);
});
})
browserType.storage.local.get('autostart', function (result) {
setAutostartOption(result);
});
});
function createButtons(result) {
let download = document.getElementById("download");
let linkType = document.createElement("h3");
linkType.innerText = result.youtube.type.charAt(0).toUpperCase() + result.youtube.type.slice(1);
let title = document.createElement("p");
title.innerText = result.youtube.title;
// dl button
let downloadButton = document.createElement("button");
downloadButton.innerText = "download";
downloadButton.id = "downloadButton";
downloadButton.setAttribute("data-id", result.youtube.url);
downloadButton.addEventListener("click", function(){downloadEvent()}, false);
// subscribe button
let subscribeButton = document.createElement("button");
subscribeButton.innerText = "subscribe";
subscribeButton.id = "subscribeButton";
subscribeButton.setAttribute("data-id", result.youtube.url);
subscribeButton.addEventListener("click", function(){subscribeEvent()}, false);
download.appendChild(linkType);
download.appendChild(title);
download.appendChild(downloadButton);
download.appendChild(subscribeButton);
download.appendChild(document.createElement("hr"));
}

View File

@ -2,596 +2,74 @@
content script running on youtube.com
*/
'use strict';
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">
.st0{display:none;}
.st1{display:inline;}
</style>
<g class="st0">
<g class="st1">
<g>
<rect x="49.8" y="437.8" width="400.4" height="32.4"/>
</g>
<g>
<g>
<path d="M49.8,193c2-9.4,7.6-16.4,14.5-22.6c2.9-2.6,5.5-5.5,8.3-8.3c13.1-12.9,31.6-13,44.6,0c23,22.9,45.9,45.9,68.8,68.8
c0.7,0.7,1.5,1.4,2.5,2.4c1.1-1.1,2.2-2.1,3.3-3.1c63.4-63.4,126.8-126.8,190.2-190.2c10.7-10.7,24.6-13.3,37.1-6.7
c2.9,1.6,5.6,3.8,8.1,6c4.2,3.9,8.2,8.1,12.2,12.1c14.3,14.3,14.3,32.4,0.1,46.6c-20.2,20.3-40.5,40.5-60.8,60.8
C321,216.8,263.2,274.6,205.4,332.4c-11.2,11.2-22.4,11.2-33.6,0c-35.7-35.7-71.4-71.6-107.3-107.2
c-6.7-6.6-12.7-13.4-14.8-22.8C49.8,199.2,49.8,196.1,49.8,193z"/>
</g>
</g>
</g>
</g>
<g>
<rect x="237.9" y="313.5" transform="matrix(-1.836970e-16 1 -1 -1.836970e-16 708.0891 208.8956)" width="23.4" height="289.9"/>
<g>
<g>
<path d="M190.6,195.1c-21.7,0-42.5,0.1-63.4,0c-8.2,0-14.4,3-17.8,10.6c-3.5,7.9-1.3,14.6,4.5,20.7
c40.6,42.4,81,84.9,121.6,127.3c8.9,9.3,19.1,9.4,28,0.1c40.7-42.5,81.3-85.1,122-127.7c5.6-5.9,7.6-12.6,4.3-20.3
c-3.3-7.6-9.5-10.8-17.7-10.7c-19,0.1-38,0-57,0c-2,0-3.9,0-6.5,0c0-2.8,0-5,0-7.1c0-42.3,0.1-84.5,0-126.8
c0-19.4-12.1-31.3-31.5-31.4c-17.9-0.1-35.8,0-53.7,0c-21.2,0-32.7,11.6-32.7,32.9c0,41.7,0,83.4,0,125.1
C190.6,190,190.6,192.2,190.6,195.1z"/>
<path d="M190.6,195.1c0-2.9,0-5.1,0-7.3c0-41.7,0-83.4,0-125.1c0-21.3,11.5-32.9,32.7-32.9c17.9,0,35.8-0.1,53.7,0
c19.4,0.1,31.5,12,31.5,31.4c0.1,42.3,0,84.5,0,126.8c0,2.2,0,4.4,0,7.1c2.5,0,4.5,0,6.5,0c19,0,38,0.1,57,0
c8.2,0,14.4,3.1,17.7,10.7c3.4,7.6,1.3,14.4-4.3,20.3c-40.7,42.6-81.3,85.2-122,127.7c-8.8,9.2-19.1,9.2-28-0.1
c-40.5-42.4-81-84.9-121.6-127.3c-5.8-6.1-8-12.8-4.5-20.7c3.4-7.6,9.6-10.7,17.8-10.6C148.1,195.2,168.9,195.1,190.6,195.1z"/>
</g>
</g>
</g>
</svg>`;
const checkmarkIcon = `<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">
.st0{display:none;}
.st1{display:inline;}
</style>
<g>
<g>
<g>
<rect x="49.8" y="437.8" width="400.4" height="32.4"/>
</g>
<g>
<g>
<path d="M49.8,193c2-9.4,7.6-16.4,14.5-22.6c2.9-2.6,5.5-5.5,8.3-8.3c13.1-12.9,31.6-13,44.6,0c23,22.9,45.9,45.9,68.8,68.8
c0.7,0.7,1.5,1.4,2.5,2.4c1.1-1.1,2.2-2.1,3.3-3.1c63.4-63.4,126.8-126.8,190.2-190.2c10.7-10.7,24.6-13.3,37.1-6.7
c2.9,1.6,5.6,3.8,8.1,6c4.2,3.9,8.2,8.1,12.2,12.1c14.3,14.3,14.3,32.4,0.1,46.6c-20.2,20.3-40.5,40.5-60.8,60.8
C321,216.8,263.2,274.6,205.4,332.4c-11.2,11.2-22.4,11.2-33.6,0c-35.7-35.7-71.4-71.6-107.3-107.2
c-6.7-6.6-12.7-13.4-14.8-22.8C49.8,199.2,49.8,196.1,49.8,193z"/>
</g>
</g>
</g>
</g>
<g class="st0">
<rect x="237.9" y="313.5" transform="matrix(-1.836970e-16 1 -1 -1.836970e-16 708.0891 208.8956)" class="st1" width="23.4" height="289.9"/>
<g class="st1">
<g>
<path d="M190.6,195.1c-21.7,0-42.5,0.1-63.4,0c-8.2,0-14.4,3-17.8,10.6c-3.5,7.9-1.3,14.6,4.5,20.7
c40.6,42.4,81,84.9,121.6,127.3c8.9,9.3,19.1,9.4,28,0.1c40.7-42.5,81.3-85.1,122-127.7c5.6-5.9,7.6-12.6,4.3-20.3
c-3.3-7.6-9.5-10.8-17.7-10.7c-19,0.1-38,0-57,0c-2,0-3.9,0-6.5,0c0-2.8,0-5,0-7.1c0-42.3,0.1-84.5,0-126.8
c0-19.4-12.1-31.3-31.5-31.4c-17.9-0.1-35.8,0-53.7,0c-21.2,0-32.7,11.6-32.7,32.9c0,41.7,0,83.4,0,125.1
C190.6,190,190.6,192.2,190.6,195.1z"/>
<path d="M190.6,195.1c0-2.9,0-5.1,0-7.3c0-41.7,0-83.4,0-125.1c0-21.3,11.5-32.9,32.7-32.9c17.9,0,35.8-0.1,53.7,0
c19.4,0.1,31.5,12,31.5,31.4c0.1,42.3,0,84.5,0,126.8c0,2.2,0,4.4,0,7.1c2.5,0,4.5,0,6.5,0c19,0,38,0.1,57,0
c8.2,0,14.4,3.1,17.7,10.7c3.4,7.6,1.3,14.4-4.3,20.3c-40.7,42.6-81.3,85.2-122,127.7c-8.8,9.2-19.1,9.2-28-0.1
c-40.5-42.4-81-84.9-121.6-127.3c-5.8-6.1-8-12.8-4.5-20.7c3.4-7.6,9.6-10.7,17.8-10.6C148.1,195.2,168.9,195.1,190.6,195.1z"/>
</g>
</g>
</g>
</svg>`;
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 browserType = getBrowser();
// boilerplate to dedect browser type api
function getBrowser() {
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
console.log('detected firefox');
return browser;
if (typeof chrome !== "undefined") {
if (typeof browser !== "undefined") {
console.log("detected firefox");
return browser;
} else {
console.log("detected chrome");
return chrome;
}
} else {
console.log('detected chrome');
return chrome;
console.log("failed to dedect browser");
throw "browser detection error"
};
}
function detectUrlType(url) {
const videoRe = new RegExp(/^https:\/\/(www\.)?(youtube.com\/watch\?v=|youtu\.be\/)[\w-]{11}/);
if (videoRe.test(url)) {
return "video"
}
} else {
console.log('failed to dedect browser');
throw 'browser detection error';
}
}
function getChannelContainers() {
const elements = document.querySelectorAll('#inner-header-container, #owner');
const channelContainerNodes = [];
elements.forEach(element => {
if (isElementVisible(element)) {
channelContainerNodes.push(element);
const channelRe = new RegExp(/^https:?\/\/www\.?youtube.com\/c|channel|user\/[\w-]+(\/|featured|videos)?$/);
if (channelRe.test(url)) {
return "channel"
}
});
return channelContainerNodes;
}
function isElementVisible(element) {
return element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
}
function ensureTALinks() {
let channelContainerNodes = getChannelContainers();
for (let channelContainer of channelContainerNodes) {
channelContainer = adjustOwner(channelContainer);
if (channelContainer.hasTA) continue;
let channelButton = buildChannelButton(channelContainer);
channelContainer.appendChild(channelButton);
channelContainer.hasTA = true;
}
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;
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;
const playlistRe = new RegExp(/^https:\/\/(www\.)?youtube.com\/playlist\?list=/);
if (playlistRe.test(url)) {
return "playlist"
}
channelContainer.taObserver = new MutationObserver(throttled(updateButtonsIfNecessary, 100));
channelContainer.taObserver.observe(channelContainer, {
attributes: true,
childList: true,
subtree: true,
return false
}
function sendUrl() {
let url = document.URL
let urlType = detectUrlType(url);
if (urlType == false) {
console.log("not relevant")
return
}
let payload = {
"youtube": {
"url": url,
"title": document.title,
"type": urlType,
}
}
console.log("youtube link: " + JSON.stringify(payload));
browserType.runtime.sendMessage(payload, function(response) {
console.log(response.farewell);
});
}
return buttonDiv;
}
};
function getChannelHandle(channelContainer) {
let channelHandle;
const videoOwnerRenderer = channelContainer.querySelector('.ytd-video-owner-renderer');
if (!videoOwnerRenderer) {
const channelHandleContainer = document.querySelector('#channel-handle');
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');
}
e.stopPropagation();
});
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);
e.stopPropagation();
});
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');
if (buttonSpan === null) {
buttonSpan = button;
}
buttonSpan.style.filter =
'invert(19%) sepia(93%) saturate(7472%) hue-rotate(359deg) brightness(105%) contrast(113%)';
buttonSpan.style.color = 'red';
button.style.opacity = 1;
button.addEventListener('mouseout', () => {
Object.assign(button.style, {
opacity: 1,
});
});
}
function buttonSuccess(button) {
let buttonSpan = button.querySelector('span');
if (buttonSpan === null) {
buttonSpan = button;
}
if (buttonSpan.innerHTML === 'Subscribe') {
buttonSpan.innerHTML = 'Success';
setTimeout(() => {
buttonSpan.innerHTML = 'Unsubscribe';
document.addEventListener("yt-navigate-finish", function (event) {
setTimeout(function(){
sendUrl();
return false;
}, 2000);
} else {
buttonSpan.innerHTML = checkmarkIcon;
}
}
function sendUrl(url, action, button) {
function handleResponse(message) {
console.log('sendUrl response: ' + JSON.stringify(message));
if (message === null || message.detail === 'Invalid token.') {
buttonError(button);
} else {
buttonSuccess(button);
}
}
function handleError(e) {
console.log('error', e);
buttonError(button);
}
let message = { type: action, url };
console.log('youtube link: ' + JSON.stringify(message));
let sending = sendMessage(message);
sending.then(handleResponse, handleError);
}
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)) {
ensureTALinks();
}
});
observer.observe(document.body, { attributes: false, childList: true, subtree: true });

View File

@ -5,8 +5,8 @@ body {
}
.container {
padding: 10px;
min-width: 350px;
max-width: 450px;
min-width: 300px;
max-width: 400px;
}
.h3 {
font-family: Sen-bold, sans-serif;
@ -48,7 +48,8 @@ hr {
align-items: center;
justify-content: center;
}
.submit button {
.submit button,
.youtube-page button {
margin: 10px;
border-radius: 0;
padding: 5px 13px;
@ -57,13 +58,14 @@ hr {
background-color: #259485;
color: #ffffff;
}
.submit button:hover {
.submit button:hover,
.youtube-page button:hover {
background-color: #97d4c8;
transform: scale(1.05);
color: #00202f;
}
.options {
display: block;
display: flex;
padding-bottom: 10px;
}
.options span {
@ -76,8 +78,4 @@ hr {
}
.icons img {
width: 25px;
}
#error-out {
color: red;
display: none; /* will be made visible when an error occurs */
}
}

1960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
{
"name": "tubearchivist-browser-extension",
"private": true,
"scripts": {
"lint": "eslint 'extension/**/*.js'",
"format": "prettier --write 'extension/**/*.js'"
},
"devDependencies": {
"eslint": "^8.26.0",
"prettier": "^2.7.1",
"eslint-config-prettier": "^8.5.0"
},
"prettier": {
"singleQuote": true,
"arrowParens": "avoid",
"printWidth": 100
}
}