feat: modernize UI with Astro+Svelte and optimize Docker build
- Migrated frontend to Astro + Svelte 5 for cyberpunk aesthetic - Switched to Bun for faster frontend builds - Implemented multi-stage Docker build for smaller image size - Refactored backend to serve static assets and proxy API requests - Added recovery mode for manual file management
|
|
@ -0,0 +1,321 @@
|
|||
/*
|
||||
extension background script listening for events
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
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;
|
||||
} else {
|
||||
return chrome;
|
||||
}
|
||||
} else {
|
||||
console.log('failed to detect 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',
|
||||
},
|
||||
});
|
||||
|
||||
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}`);
|
||||
if (!path.endsWith('cookie/')) console.log(`${method}: ${JSON.stringify(payload)}`);
|
||||
|
||||
try {
|
||||
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),
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// check if cookie is valid
|
||||
async function getCookieState() {
|
||||
const path = 'api/appsettings/cookie/';
|
||||
let response = await sendGet(path);
|
||||
console.log('cookie state: ' + JSON.stringify(response));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// send ping to server
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
);
|
||||
}
|
||||
|
||||
async function subscribe(url, subscribed) {
|
||||
return await sendData(
|
||||
'api/channel/',
|
||||
{
|
||||
data: [
|
||||
{
|
||||
channel_id: url,
|
||||
channel_subscribed: subscribed,
|
||||
},
|
||||
],
|
||||
},
|
||||
'POST'
|
||||
);
|
||||
}
|
||||
|
||||
async function videoExists(id) {
|
||||
const path = `api/video/${id}/`;
|
||||
let response = await sendGet(path);
|
||||
if (response?.error) return false;
|
||||
let access = await getAccess();
|
||||
return new URL(`video/${id}/`, `${access.url}:${access.port}/`).href;
|
||||
}
|
||||
|
||||
async function getChannel(channelHandle) {
|
||||
const path = `api/channel/search/?q=${channelHandle}`;
|
||||
try {
|
||||
return await sendGet(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cookieStr(cookieLines) {
|
||||
const path = 'api/appsettings/cookie/';
|
||||
let payload = {
|
||||
cookie: cookieLines.join('\n'),
|
||||
};
|
||||
let response = await sendData(path, payload, 'PUT');
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function buildCookieLine(cookie) {
|
||||
// 2nd argument controls subdomains, and must match leading dot in domain
|
||||
let includeSubdomains = cookie.domain.startsWith('.') ? 'TRUE' : 'FALSE';
|
||||
|
||||
return [
|
||||
cookie.domain,
|
||||
includeSubdomains,
|
||||
cookie.path,
|
||||
cookie.httpOnly.toString().toUpperCase(),
|
||||
Math.trunc(cookie.expirationDate) || 0,
|
||||
cookie.name,
|
||||
cookie.value,
|
||||
].join('\t');
|
||||
}
|
||||
|
||||
async function getCookieLines() {
|
||||
const acceptableDomains = ['.youtube.com', 'youtube.com', 'www.youtube.com'];
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieLines;
|
||||
}
|
||||
|
||||
async function sendCookies() {
|
||||
console.log('function sendCookies');
|
||||
let cookieLines = await getCookieLines();
|
||||
let response = cookieStr(cookieLines);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
let listenerEnabled = false;
|
||||
let isThrottled = false;
|
||||
|
||||
async function handleContinuousCookie(checked) {
|
||||
if (checked === true) {
|
||||
browserType.cookies.onChanged.addListener(onCookieChange);
|
||||
listenerEnabled = true;
|
||||
console.log('Cookie listener enabled');
|
||||
} else {
|
||||
browserType.cookies.onChanged.removeListener(onCookieChange);
|
||||
listenerEnabled = false;
|
||||
console.log('Cookie listener disabled');
|
||||
}
|
||||
}
|
||||
|
||||
function onCookieChange(changeInfo) {
|
||||
if (!isThrottled) {
|
||||
isThrottled = true;
|
||||
|
||||
console.log('Cookie event detected:', changeInfo);
|
||||
|
||||
sendCookies();
|
||||
|
||||
setTimeout(() => {
|
||||
isThrottled = false;
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
process and return message if needed
|
||||
the following messages are supported:
|
||||
type Message =
|
||||
| { type: 'verify' }
|
||||
| { type: 'cookieState' }
|
||||
| { type: 'sendCookie' }
|
||||
| { type: 'getCookieLines' }
|
||||
| { type: 'continuousSync', checked: boolean }
|
||||
| { 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 'getCookieLines': {
|
||||
return await getCookieLines();
|
||||
}
|
||||
case 'continuousSync': {
|
||||
return await handleContinuousCookie(request.checked);
|
||||
}
|
||||
case 'download': {
|
||||
return await download(request.url);
|
||||
}
|
||||
case 'subscribe': {
|
||||
return await subscribe(request.url, true);
|
||||
}
|
||||
case 'unsubscribe': {
|
||||
let channel = await getChannel(request.url);
|
||||
return await subscribe(channel.channel_id, 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.log(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;
|
||||
}
|
||||
|
||||
browserType.runtime.onMessage.addListener(handleMessage);
|
||||
|
||||
browserType.runtime.onStartup.addListener(() => {
|
||||
browserType.storage.local.get('continuousSync', data => {
|
||||
handleContinuousCookie(data?.continuousSync?.checked || false);
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<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 374.4 375.1" style="enable-background:new 0 0 374.4 375.1;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<path class="st0" d="M187.5,0C84.2-0.1,0.1,83.8,0,187.2C-0.2,290.8,83.7,375,187.1,375.1c103.3,0,187.2-83.9,187.3-187.4
|
||||
C374.5,84.3,290.7,0.2,187.5,0z M187.9,304.5c-16,0-29.2-13.3-29.2-29.3c0.1-16,13.2-29,29.1-29.1c16,0,29.2,13.3,29.2,29.3
|
||||
C216.9,291.4,203.8,304.5,187.9,304.5z M238.2,195.7c-4.8,2.9-9.7,5.8-14.5,8.7c-9.1,5.5-13.6,13.6-13.9,24.2c0,0.8,0,1.7,0,2.7
|
||||
c-14.6,0-29,0-43.8,0c0.1-2.8,0.2-5.6,0.4-8.5c0.8-10.6,3.2-20.9,8.4-30.3c5.9-11,14.9-18.9,25.5-25.1c4.1-2.4,8.4-4.7,12.5-7.1
|
||||
c12.7-7.7,13.1-19.1,6.2-29.9c-6.5-10.3-16.3-14.9-28.1-15.7c-8-0.5-15.7,0.7-22.6,4.9c-10.1,6.2-15,15.6-16.9,26.9
|
||||
c-0.1,0.4-0.1,0.9-0.2,1.6c-14.5-1.9-28.9-3.8-43.9-5.8c1.4-5.4,2.5-10.8,4.2-15.9c5.2-14.9,13.6-27.8,25.8-38
|
||||
c11.1-9.3,23.9-14.7,38.2-16.6c2.3-0.3,4.6-0.6,6.9-1c3.2,0,6.4,0,9.6,0c4.4,0.6,8.9,1.1,13.3,1.9c29.8,5.9,55.4,28.1,61.4,59.3
|
||||
C271.5,157.5,260.7,182.2,238.2,195.7z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<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 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M191.57,379.5C88.15,379.44,4.32,295.25,4.46,191.57C4.6,88.18,88.66,4.28,191.95,4.42
|
||||
c103.29,0.14,187.02,84.23,186.93,187.72C378.8,295.6,294.86,379.55,191.57,379.5z M244.55,271.24
|
||||
c4.89,5.91,9.44,11.19,13.73,16.69c1.8,2.32,3.72,3.11,6.64,2.93c28.39-1.75,53.17-11.56,72.96-32.55c1.12-1.19,2.08-3.2,1.97-4.76
|
||||
c-0.79-11.71-1.27-23.48-2.97-35.07c-5.07-34.65-16.23-67.36-32.74-98.23c-0.73-1.36-1.88-2.67-3.15-3.55
|
||||
c-19.34-13.43-40.61-21.62-64.16-23.63c-3.19-0.27-5.5,0.48-6.89,3.09c10.27,3.97,20.59,7.65,30.65,11.94
|
||||
c10.1,4.32,19.64,9.74,28.21,16.92c-64.82-29.49-129.53-29.49-194.34,0.02c17.77-13.75,38.02-22.07,59.27-28.39
|
||||
c-1.74-3.15-4.12-3.86-7.32-3.58c-23.15,2.02-44.13,9.94-63.2,23.08c-1.76,1.21-3.51,2.96-4.4,4.86
|
||||
c-5.36,11.39-11.02,22.69-15.57,34.41c-12.22,31.43-19.35,64-19.75,97.86c-0.02,1.71,0.88,3.85,2.07,5.1
|
||||
c18.74,19.74,41.98,29.72,68.88,32.19c5.73,0.53,9.64-0.46,12.96-5.26c3.36-4.86,7.35-9.28,11.2-14.05
|
||||
c-22.46-8.05-39.37-18.53-42.93-26.17c63.97,36.11,128.17,36.13,192.51-0.02C276.69,258.45,261.71,265.95,244.55,271.24z"/>
|
||||
<path class="st0" d="M237.28,232.74c-14.34-0.02-26.07-12-25.9-26.45c0.17-14.67,11.86-26.25,26.56-26.33
|
||||
c13.63-0.07,25.18,12,25.21,26.35C263.18,220.98,251.66,232.76,237.28,232.74z"/>
|
||||
<path class="st0" d="M146.11,232.74c-14.35,0.05-25.9-11.7-25.92-26.38c-0.02-14.3,11.56-26.46,25.16-26.41
|
||||
c14.85,0.06,26.74,11.96,26.62,26.66C171.84,221.04,160.32,232.69,146.11,232.74z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<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 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M244.51,375.15c0-10.59-0.31-21.12,0.08-31.61c0.5-13.65,0.02-26.89-10.41-37.88
|
||||
c8.18-2.06,15.59-3.84,22.95-5.83c3.51-0.95,6.95-2.22,10.37-3.47c27.26-9.99,40.88-31.15,45.79-58.73
|
||||
c2.71-15.21,4.11-30.82-0.74-45.88c-2.65-8.23-6.01-16.69-11.08-23.5c-3.83-5.14-3.43-9.05-2.23-14.27
|
||||
c2.86-12.48,0.71-24.71-2.64-36.79c-0.99-3.58-3.09-4.99-6.88-4.6c-11.45,1.18-21.48,6.12-31.34,11.6
|
||||
c-1.42,0.79-2.97,1.46-4.16,2.52c-5.55,4.99-11.4,5.13-18.58,3.42c-25.28-6.04-50.67-5.14-75.85,1.12
|
||||
c-3.59,0.89-6.27,0.27-9.31-1.86c-11.08-7.73-22.87-14.02-36.37-16.31c-0.13-0.02-0.26-0.07-0.4-0.09
|
||||
c-7.12-1.23-8.12-0.76-10.11,6.16c-3.51,12.23-5.18,24.6-1.55,37.14c1.14,3.94,0.44,7.16-2.01,10.61
|
||||
c-23.14,32.46-16.36,80.65,0.11,103.41c12.16,16.81,29.02,26.2,48.63,31.06c5.49,1.36,11.11,2.15,17.1,3.28
|
||||
c-0.24,0.79-0.43,1.81-0.85,2.71c-3.15,6.91-6.27,13.83-9.6,20.65c-0.67,1.38-2.06,3.14-3.34,3.35c-7.3,1.18-14.65,2.46-22.02,2.7
|
||||
c-6.5,0.21-12.09-3.32-16.37-8c-5.11-5.59-9.65-11.73-14.19-17.83c-6.07-8.15-14.2-12.42-24.05-13.3c-2.22-0.2-4.58,1.12-7.99,2.05
|
||||
c2.69,3.23,4.46,6.17,6.96,8.2c10.32,8.35,17.42,19.1,22.26,31.24c4.72,11.85,14.1,16.55,25.31,19.27
|
||||
c9.24,2.24,18.51,1.5,27.83,0.78c6.07-0.47,6.09-0.22,6.09,5.98c0.01,5.58,0,11.17,0,16.81C86.7,368.22,7.24,301.96,4.67,198.28
|
||||
C2.19,97.88,77.65,13.69,175.94,5.34c103.69-8.81,192.95,68.52,202.14,171.23C387.22,278.72,317.17,355.78,244.51,375.15z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<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 384 384" style="enable-background:new 0 0 384 384;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M240.82,274c-3.86,3.78-8.45,6.33-13.43,8.26c-9.77,3.79-19.98,5.34-30.38,5.85
|
||||
c-5.42,0.26-10.85,0.12-16.26-0.44c-5.89-0.61-11.72-1.56-17.42-3.18c-7.65-2.17-14.86-5.18-20.68-10.86
|
||||
c-0.98-0.96-2.26-1.44-3.64-1.48c-2.72-0.08-4.71,1.17-5.94,3.55c-1.14,2.2-0.8,4.87,0.94,6.7c1.28,1.35,2.7,2.59,4.17,3.73
|
||||
c5.88,4.57,12.58,7.49,19.65,9.63c11.11,3.37,22.52,4.66,36.04,4.85c2.63-0.18,7.2-0.42,11.76-0.82c6.75-0.6,13.38-1.86,19.88-3.8
|
||||
c6.56-1.95,12.83-4.56,18.42-8.57c1.94-1.4,3.82-2.93,5.52-4.61c2.44-2.4,2.35-6.62,0.01-8.95
|
||||
C247.1,271.52,243.37,271.51,240.82,274z"/>
|
||||
<path class="st0" d="M192.39,4.72C89.03,4.5,5.07,88.18,4.86,191.61c-0.21,103.43,83.4,187.46,186.75,187.67
|
||||
c103.35,0.21,187.31-83.46,187.53-186.89C379.36,88.96,295.74,4.93,192.39,4.72z M327.66,225.47c0.14,1.32,0.27,2.64,0.41,3.96
|
||||
c0.53,4.98,0.43,9.96-0.23,14.91c-2.24,16.85-10.03,30.98-21.45,43.28c-8.94,9.62-19.42,17.22-30.95,23.42
|
||||
c-13.96,7.52-28.79,12.64-44.3,15.87c-8.46,1.76-17,2.91-25.62,3.51c-6.58,0.46-13.16,0.6-19.75,0.39
|
||||
c-6.8-0.22-13.58-0.8-20.34-1.72c-11.93-1.64-23.6-4.38-34.96-8.39c-14.15-4.99-27.38-11.73-39.34-20.86
|
||||
c-9.74-7.44-18.15-16.12-24.63-26.59c-5.11-8.26-8.64-17.14-10.2-26.75c-1.1-6.76-1.23-13.55-0.14-20.33c0.03-0.21,0-0.44,0-0.7
|
||||
c-10.51-5.25-17.19-13.54-19.32-25.14c-1.71-9.32,0.32-17.97,5.8-25.72c11.73-16.6,35.97-19.53,51.41-4.96
|
||||
c27.77-19.23,59-27.5,92.46-28.92c0.46-2.12,0.92-4.2,1.36-6.28c3.46-16.26,6.92-32.53,10.37-48.79
|
||||
c1.82-8.57,3.63-17.14,5.43-25.71c0.26-1.23,0.59-2.42,1.44-3.4c1.68-1.92,3.83-2.34,6.2-1.85c6.31,1.3,12.62,2.66,18.92,3.99
|
||||
c10.35,2.19,20.7,4.39,31.05,6.59c2,0.42,4,0.84,6.09,1.28c3.7-6.86,9.29-11.3,16.92-12.93c5.54-1.19,10.89-0.41,15.96,2.14
|
||||
c10.03,5.06,15.32,16.56,12.65,27.42c-2.74,11.12-12.68,18.73-24.01,18.65c-11.69-0.08-23.34-9.38-24.16-23.21
|
||||
c-1.43-0.6-49.41-10.81-50.5-10.7c-5.15,24.12-10.32,48.27-15.51,72.57c0.64,0.04,1.07,0.07,1.51,0.09
|
||||
c19.74,0.93,38.91,4.68,57.37,11.79c11.05,4.25,21.5,9.64,31.21,16.44c0.29,0.2,0.6,0.38,0.94,0.59
|
||||
c8.62-7.75,18.62-11.01,30.07-8.89c9.47,1.76,16.97,6.78,22.22,14.87c5.67,8.73,7.06,18.26,4.21,28.25
|
||||
C343.42,213.65,337.03,220.82,327.66,225.47z"/>
|
||||
<path class="st0" d="M162.26,218.68c0.08-13.49-10.62-24.33-24.09-24.41c-13.86,0-24.09,11.31-24.22,23.89
|
||||
c-0.13,12.52,9.75,24.24,23.96,24.42C151.22,242.73,162.19,231.9,162.26,218.68z"/>
|
||||
<path class="st0" d="M245.16,194.56c-13.42,0.03-24.09,10.83-24.11,24.24c-0.02,12.58,10.14,24.01,24.17,24.05
|
||||
c13.33,0.04,24.16-10.89,24.12-24.26C269.32,205.3,258.48,194.52,245.16,194.56z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -0,0 +1,71 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TubeArchivist Companion</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapi.com/css?family=Sen" type="text/css">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<a href="#" id="ta-url" target="_blank">
|
||||
<img src="/images/logo.png" alt="ta-logo">
|
||||
</a>
|
||||
<span>v0.4.2</span>
|
||||
</div>
|
||||
<hr>
|
||||
<form class="login-form">
|
||||
<label for="full-url">Tube Archivist URL:</label>
|
||||
<input type="text" id="full-url" name="url">
|
||||
<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">☐</span>
|
||||
</div>
|
||||
<div id="error-out"></div>
|
||||
<hr>
|
||||
<p>Cookies:</p>
|
||||
<div class="options">
|
||||
<div>
|
||||
<p>TA cookies: <span id="sendCookiesStatus" /></p>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" id="continuous-sync" name="continuous-sync">
|
||||
<span>Continuous Cookie Sync</span>
|
||||
</div>
|
||||
<button id="sendCookies">Copy Now</button>
|
||||
<button id="showCookies">Show Cookie</button><br>
|
||||
<textarea id="cookieLinesResponse" readonly></textarea>
|
||||
</div>
|
||||
<p>Download:</p>
|
||||
<div class="options">
|
||||
<div>
|
||||
<input type="checkbox" id="autostart" name="autostart">
|
||||
<span>Autostart Downloads</span>
|
||||
</div>
|
||||
</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">
|
||||
<img src="/images/social/discord.svg" alt="discord-icon"></a>
|
||||
<a href="https://github.com/tubearchivist/browser-extension/" target="_blank">
|
||||
<img src="/images/social/github.svg" alt="github-icon">
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/tubearchivist/browser-extension/issues">
|
||||
<img src="/images/question.svg" alt="question-icon">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "TubeArchivist Companion",
|
||||
"description": "Interact with your selfhosted TA server.",
|
||||
"version": "0.4.2",
|
||||
"icons": {
|
||||
"48": "/images/icon.png",
|
||||
"128": "/images/icon128.png"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"cookies"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://*.youtube.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://www.youtube.com/*"],
|
||||
"js": ["script.js"]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "TubeArchivist Companion",
|
||||
"description": "Interact with your selfhosted TA server.",
|
||||
"version": "0.4.2",
|
||||
"icons": {
|
||||
"128": "/images/icon128.png"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": "/images/icon.png",
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"cookies",
|
||||
"https://*.youtube.com/*"
|
||||
],
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://www.youtube.com/*"],
|
||||
"js": ["script.js"]
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
}
|
||||
}
|
||||
297
browser-extension/browser-extension-0.4.2/extension/popup.js
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/*
|
||||
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;
|
||||
} else {
|
||||
return chrome;
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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
|
||||
document.getElementById('status-icon').addEventListener('click', function () {
|
||||
pingBackend();
|
||||
});
|
||||
|
||||
// send cookie
|
||||
document.getElementById('sendCookies').addEventListener('click', function () {
|
||||
sendCookie();
|
||||
});
|
||||
|
||||
// show cookies
|
||||
document.getElementById('showCookies').addEventListener('click', function () {
|
||||
showCookies();
|
||||
});
|
||||
|
||||
// continuous sync
|
||||
document.getElementById('continuous-sync').addEventListener('click', function () {
|
||||
toggleContinuousSync();
|
||||
});
|
||||
|
||||
// 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));
|
||||
let validattionMessage = `enabled, last verified ${message.validated_str}`;
|
||||
document.getElementById('sendCookiesStatus').innerText = validattionMessage;
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.log(`Error: ${error}`);
|
||||
setError(error);
|
||||
}
|
||||
|
||||
let sending = sendMessage({ type: 'sendCookie' });
|
||||
sending.then(handleResponse, handleError);
|
||||
}
|
||||
|
||||
function showCookies() {
|
||||
console.log('popup show cookies');
|
||||
const textArea = document.getElementById('cookieLinesResponse');
|
||||
|
||||
function handleResponse(message) {
|
||||
textArea.value = message.join('\n');
|
||||
textArea.style.display = 'initial';
|
||||
}
|
||||
function handleError(error) {
|
||||
console.log(`Error: ${error}`);
|
||||
}
|
||||
|
||||
if (textArea.value) {
|
||||
textArea.value = '';
|
||||
textArea.style.display = 'none';
|
||||
document.getElementById('showCookies').textContent = 'Show Cookie';
|
||||
} else {
|
||||
let sending = sendMessage({ type: 'getCookieLines' });
|
||||
sending.then(handleResponse, handleError);
|
||||
document.getElementById('showCookies').textContent = 'Hide Cookie';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleContinuousSync() {
|
||||
const checked = document.getElementById('continuous-sync').checked;
|
||||
let toStore = {
|
||||
continuousSync: {
|
||||
checked: checked,
|
||||
},
|
||||
};
|
||||
browserType.storage.local.set(toStore, function () {
|
||||
console.log('stored option: ' + JSON.stringify(toStore));
|
||||
});
|
||||
sendMessage({ type: 'continuousSync', checked });
|
||||
}
|
||||
|
||||
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
|
||||
async 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function setCookieState() {
|
||||
clearError();
|
||||
function handleResponse(message) {
|
||||
console.log(message);
|
||||
if (!message.cookie_enabled) {
|
||||
document.getElementById('sendCookiesStatus').innerText = 'disabled';
|
||||
} else {
|
||||
let validattionMessage = 'enabled';
|
||||
if (message.validated_str) {
|
||||
validattionMessage += `, last verified ${message.validated_str}`;
|
||||
}
|
||||
document.getElementById('sendCookiesStatus').innerText = validattionMessage;
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.log(`Error: ${error}`);
|
||||
setError(error);
|
||||
}
|
||||
|
||||
console.log('set cookie state');
|
||||
let sending = sendMessage({ type: 'cookieState' });
|
||||
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 = '☑';
|
||||
statusIcon.style.color = 'green';
|
||||
} else {
|
||||
statusIcon.innerHTML = '☒';
|
||||
statusIcon.style.color = 'red';
|
||||
}
|
||||
}
|
||||
|
||||
// fill in form
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
setCookieState();
|
||||
}
|
||||
|
||||
async function setContinuousCookiesOptions(result) {
|
||||
if (!result.continuousSync || result.continuousSync.checked === false) {
|
||||
console.log('continuous cookie sync not set');
|
||||
return;
|
||||
}
|
||||
console.log('set options: ' + JSON.stringify(result));
|
||||
document.getElementById('continuous-sync').checked = true;
|
||||
}
|
||||
|
||||
async 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('continuousSync', function (result) {
|
||||
setContinuousCookiesOptions(result);
|
||||
});
|
||||
|
||||
browserType.storage.local.get('autostart', function (result) {
|
||||
setAutostartOption(result);
|
||||
});
|
||||
});
|
||||
617
browser-extension/browser-extension-0.4.2/extension/script.js
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
/*
|
||||
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;
|
||||
} else {
|
||||
console.log('detected chrome');
|
||||
return chrome;
|
||||
}
|
||||
} else {
|
||||
console.log('failed to dedect browser');
|
||||
throw 'browser detection error';
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelContainers() {
|
||||
const elements = document.querySelectorAll(
|
||||
'.yt-page-header-view-model__page-header-flexible-actions, #owner'
|
||||
);
|
||||
const channelContainerNodes = [];
|
||||
|
||||
elements.forEach(element => {
|
||||
if (isElementVisible(element) && window.location.pathname !== '/playlist') {
|
||||
channelContainerNodes.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
channelContainer.taObserver = new MutationObserver(throttled(updateButtonsIfNecessary, 100));
|
||||
channelContainer.taObserver.observe(channelContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
return buttonDiv;
|
||||
}
|
||||
|
||||
function getChannelHandle(channelContainer) {
|
||||
function findeHandleString(container) {
|
||||
let result = null;
|
||||
|
||||
function recursiveTraversal(element) {
|
||||
for (let child of element.children) {
|
||||
if (child.tagName === 'A' && child.hasAttribute('href')) {
|
||||
const href = child.getAttribute('href');
|
||||
const match = href.match(/\/@[^/]+/); // Match the path starting with "@"
|
||||
if (match) {
|
||||
// handle is in channel link
|
||||
result = match[0].substring(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (child.children.length === 0 && child.textContent.trim().startsWith('@')) {
|
||||
// handle is in channel description text
|
||||
result = child.textContent.trim();
|
||||
return;
|
||||
}
|
||||
|
||||
recursiveTraversal(child);
|
||||
if (result) return;
|
||||
}
|
||||
}
|
||||
|
||||
recursiveTraversal(container);
|
||||
return result;
|
||||
}
|
||||
|
||||
let channelHandle = findeHandleString(channelContainer.parentElement);
|
||||
|
||||
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';
|
||||
}, 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 });
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
body {
|
||||
font-family: Sen-Regular, sans-serif;
|
||||
background-color: #00202f;
|
||||
color: #97d4c8;
|
||||
}
|
||||
.container {
|
||||
padding: 10px;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
}
|
||||
.h3 {
|
||||
font-family: Sen-bold, sans-serif;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
hr {
|
||||
height: 1px;
|
||||
color: white;
|
||||
background-color: white;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button {
|
||||
margin: 10px;
|
||||
border-radius: 0;
|
||||
padding: 5px 13px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background-color: #259485;
|
||||
color: #ffffff;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #97d4c8;
|
||||
transform: scale(1.05);
|
||||
color: #00202f;
|
||||
}
|
||||
#download {
|
||||
text-align: center
|
||||
}
|
||||
#status-icon {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.logo {
|
||||
position: relative;
|
||||
}
|
||||
.logo img {
|
||||
width: 400px;
|
||||
}
|
||||
.logo span {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 0;
|
||||
}
|
||||
.login-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.login-form label,
|
||||
.login-form input {
|
||||
margin: 3px 0;
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.options {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.options span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.icons img {
|
||||
width: 25px;
|
||||
}
|
||||
#error-out {
|
||||
color: red;
|
||||
display: none; /* will be made visible when an error occurs */
|
||||
}
|
||||
#cookieLinesResponse {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||