Compare commits

...

128 Commits

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

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

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

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

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

* Delete assets/screenshot-search.png

* Delete assets/screenshot-channel.png

* Add files via upload

* Update README.md

* Delete assets/screenshot-channel.png

* Delete assets/screenshot.png

* Delete assets/screenshot-search.png

* Add files via upload

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

* surface errors to user in popup.js

* move logic into background.js

* split youtube message

* handle errors from URL constructor
2023-02-20 10:24:27 +07:00
simon c7069d90cb
print last tags before deploy 2023-01-14 09:29:29 +07:00
simon 9de3d65e28
update hover area docs 2023-01-14 09:17:58 +07:00
simon 44af4dc0b2
add note about channel subpages 2023-01-13 15:49:11 +07:00
simon 265c8f1e7d
update version to v0.1.2 2023-01-13 15:38:42 +07:00
simon 6da4662007
linter... linter... 2023-01-13 11:41:14 +07:00
simon 9d75b13886
add link text to title for channel dl button 2023-01-12 23:35:51 +07:00
simon 55ef05cc2a build links for shorts videos 2023-01-12 22:55:51 +07:00
Simon 8b71d15036
Merge pull request #14 from pairofcrocs/master
Updated screenshots
2022-12-07 10:16:21 +07:00
crocs c292d03b3c
Update README.md 2022-12-06 20:31:38 -06:00
crocs d239fcf045
Update README.md 2022-12-06 20:31:28 -06:00
crocs 80901021d2
Add files via upload 2022-12-06 20:29:23 -06:00
crocs ee93db2c00
Delete companion-channel.jpg 2022-12-06 20:29:14 -06:00
crocs 2e28bf25b6
Delete companion-sc.jpg 2022-12-06 20:29:08 -06:00
crocs ade0f1e165
Add files via upload 2022-12-06 20:28:27 -06:00
crocs d2ac512296
Delete screenshot-search.png 2022-12-06 20:26:26 -06:00
crocs ec9834f757
Add files via upload 2022-12-06 20:25:56 -06:00
Simon 5ae0a1baf4
Merge pull request #13 from bakkot/easier-download-link
Easier download link
2022-12-03 20:37:46 +07:00
Kevin Gibbons a2e167c9cf fix title 2022-12-02 21:28:28 -08:00
Kevin Gibbons b19b09bb84 make download button on thumbnail easier to find 2022-12-02 21:14:55 -08:00
Simon 9fadbd5c15
Merge pull request #12 from bakkot/lint
Lint
2022-12-03 12:12:29 +07:00
Kevin Gibbons 97deb2141c add readme section about JS 2022-12-02 19:00:22 -08:00
Kevin Gibbons d01803f605 add github workflow to enforce linting of JS files 2022-12-02 18:57:42 -08:00
Kevin Gibbons 9c535d27e6 manually fix remaining lint errors 2022-12-02 18:57:04 -08:00
Kevin Gibbons ee7da0e726 auto-fix lint 2022-12-02 18:52:33 -08:00
Kevin Gibbons 64d29b5a4f format 2022-12-02 18:52:08 -08:00
Kevin Gibbons 2dc650c76b add linter and formatter 2022-12-02 18:52:08 -08:00
simon f62f584dda
update discord link to redirect 2022-12-03 09:36:37 +07:00
simon 1d8dda3b81
update release version 2022-12-02 14:20:35 +07:00
Simon 52b65bf48b
Merge pull request #11 from bakkot/protocol-has-colon
fix port parsing logic
2022-12-02 14:18:35 +07:00
simon d3e647d0cf
fix video button overlap, #10 2022-12-02 14:17:56 +07:00
Kevin Gibbons 18bfa28452 fix port parsing logic 2022-12-01 17:30:24 -08:00
simon 030fb2d223
move currentLocation into event listener 2022-11-29 20:05:06 +07:00
simon b34d8a822c
update artwork and documentation 2022-11-28 20:02:07 +07:00
simon 69e02e72be
bump version to v0.1.0 2022-11-28 20:01:25 +07:00
simon 4bafe4d3bb
fix linebreak popup width 2022-11-28 16:22:56 +07:00
simon f9dd81c4f6
update roadmap 2022-11-28 11:50:03 +07:00
Simon d7339d4998
Merge pull request #6 from bakkot/patch-1
change "Tube Archivist IP" to "Tube Archivist URL"
2022-11-28 11:49:39 +07:00
Kevin Gibbons 25dd6dbdc5 fix it better 2022-11-27 20:01:06 -08:00
Kevin Gibbons 4ea141c6fe typos 2022-11-27 19:21:13 -08:00
Kevin Gibbons afbcb5757e use full url 2022-11-27 15:17:11 -08:00
Kevin Gibbons d5f5557e06 change "Tube Archivist IP" to "Tube Archivist URL" 2022-11-27 14:56:16 -08:00
simon 59709c4c29
timeout reset button text after click 2022-11-25 19:14:52 +07:00
simon 6b6a9b8b02
update companion icons 2022-11-25 17:01:34 +07:00
simon 122114d099
remove reduntant popup elements 2022-11-24 16:31:13 +07:00
simon e87468900c
throttle block for observer 2022-11-24 16:16:03 +07:00
simon 30f80ca01a
implement button press callback 2022-11-24 15:39:05 +07:00
simon 1aaf4a4411
add roadmap 2022-11-24 11:04:37 +07:00
simon 4f0452cf5f
fix /watch mouseover dl link 2022-11-24 09:00:41 +07:00
simon a0e4a10f1f
sendUrl from injected button 2022-11-24 08:36:22 +07:00
simon 2485715818
thumbnail hover button 2022-11-23 21:05:22 +07:00
simon 71d915eff1
rewrite basics for observer 2022-11-23 17:43:40 +07:00
simon da06c2c78b
fix screenshot resolution 2022-07-03 19:45:11 +07:00
simon dfb0330edc
update to v0.0.3 2022-07-03 16:58:39 +07:00
simon dff62fb123
notes for cookie sync 2022-07-03 16:30:31 +07:00
simon 7fe2165215
add questions link 2022-06-27 09:27:04 +07:00
simon a3bbc863c9
handle session epire cookies, force int expire 2022-06-25 20:36:41 +07:00
simon 68fae9d6d4
put cookie to API, checkbox UI 2022-06-25 20:15:19 +07:00
simon 65f6fe3257
add banner and tiles to readme 2022-06-25 18:49:44 +07:00
simon ff0e8c1185
send cookie to api 2022-06-22 19:46:37 +07:00
simon 5b3b882a30
convert sendPost to generic sendData 2022-06-21 06:50:12 +07:00
simon 4838c28557
move getAccess into function calls 2022-06-20 18:40:17 +07:00
simon fa0fecffcf
fix header building newline 2022-06-20 18:37:02 +07:00
simon adc37f4f5d
fix host_permission 2022-06-20 18:34:27 +07:00
simon 9903133e05
implement basic cookie builder 2022-06-20 17:35:57 +07:00
simon 7007a920c1
fix regex for matching urls with - in id 2022-06-17 16:47:40 +07:00
simon fb642282a9
a note about update delay, new screenshot 2022-06-07 18:52:24 +07:00
simon bdfebfed57
fix push repo 2022-06-04 09:40:10 +07:00
29 changed files with 3190 additions and 384 deletions

24
.eslintrc.js Normal file
View File

@ -0,0 +1,24 @@
'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',
},
};

16
.github/workflows/lint_js.yml vendored Normal file
View File

@ -0,0 +1,16 @@
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,3 +3,6 @@ extension/manifest.json
# release builds
release/*
# JavaScript stuff
node_modules

View File

@ -1,45 +1,91 @@
# Tube Archivist Companion
![Tube Archivist Companion](assets/tube-archivist-companion-banner.png?raw=true "Tube Archivist Companion Banner")
![popup screenshot](assets/screenshot.png?raw=true "Tube Archivist Companion Popup")
<h1 align="center">Browser Extension for Tube Archivist</h1>
<div align="center">
<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>
A browser extension to bridge YouTube with your Tube Archivist service.
## 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.
- 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>
## Install
- Firefox: The addon is available on the [Extension store](https://addons.mozilla.org/addon/tubearchivist-companion/).
- Chrome: The addon is available on the [Chrome Web Store](https://chrome.google.com/webstore/detail/tubearchivist-companion/jjnkmicfnfojkkgobdfeieblocadmcie).
## Update
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 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.
- **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.
- **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 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.
- **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 of your Tube Archivist instance.
A green checkmark will appear next to the *Save* button if your connection is working.
## All great things start small
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
## Options
- **Sync YouTube cookies**: Send your cookies to TubeArchivist to use for yt-dlp requests.
- **Autostart**: Autostart and prioritize videos send from this extension.
## Test this extension
Use the correct manifest file for your browser. Either rename the browser specific file to `manifest.json` before loading the addon or symlink it to the correct location, e.g. `ln -s manifest-firefox.json manifest.json`.
Before continuing loading the temporary extension here, make sure to deactivate/delete the main extension first.
Symlink/copy the correct manifest file for your browser to the expected location, e.g. `ln -s manifest-firefox.json manifest.json`.
- Firefox
- Open `about:debugging#/runtime/this-firefox`
- Click on *Load Temporary Add-on*
- Select the *manifest.json* file to load the addon.
- Select the *manifest.json* file to load the addon.
- You can *inspect* background.js by lunching the debug tools from there.
- 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 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.
- 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.
## Help needed
Join us on [Discord](https://discord.gg/AFwz8nE7BK) and help us improve and extend this project.
## 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.

BIN
assets/icon-128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
assets/screenshot-video.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
assets/tac-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -8,6 +8,9 @@ if [[ $(basename "$(pwd)") != 'tubearchivist_browserextension' ]]; then
exit 1
fi
echo "latest tags:"
git tag | tail -n 5 | sort -r
printf "\ncreate new version:\n"
read -r VERSION
@ -38,7 +41,7 @@ function create_zip {
function create_release {
git tag -a "$VERSION" -m "new release version $VERSION"
git push all "$VERSION"
git push origin "$VERSION"
}

View File

@ -2,162 +2,303 @@
extension background script listening for events
*/
console.log("running background.js");
'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;
}
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
return browser;
} else {
console.log("failed to dedect browser");
throw "browser detection error"
};
return chrome;
}
} else {
console.log('failed to detect browser');
throw 'browser detection error';
}
}
// send get request to API backend
async function sendGet(path, access) {
async function sendGet(path) {
let access = await getAccess();
const url = `${access.url}:${access.port}/${path}`;
console.log('GET: ' + url);
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 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;
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)}`);
// send post request to API backend
async function sendPost(path, access, payload) {
const url = `${access.url}:${access.port}/${path}`;
console.log("POST: " + url);
console.log("POST: " + JSON.stringify(payload))
try {
const rawResponse = await fetch(url, {
method: "POST",
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');
var storage = await browserType.storage.local.get("access");
return storage.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));
// send ping to server, return 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));
const path = "api/ping/";
let access = await getAccess();
let response = await sendGet(path, access)
console.log("verify connection: " + JSON.stringify(response));
return response
}
// store last youtube link
function setYoutubeLink(data) {
browserType.storage.local.set(data, function() {
console.log("Stored history: " + JSON.stringify(data));
});
}
// send download task to server, return response
async function downloadLink(toDownload) {
const path = "api/download/";
let payload = {
"data": [
{
"youtube_id": toDownload,
"status": "pending",
}
]
}
let access = await getAccess();
let response = await sendPost(path, access, payload)
return response
}
async function subscribeLink(toSubscribe) {
const path = "api/channel/";
let payload = {
"data": [
{
"channel_id": toSubscribe,
"channel_subscribed": true,
}
]
}
let access = await getAccess();
let response = await sendPost(path, access, payload);
return response
}
// 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)
})
}
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.data) return false;
let access = await getAccess();
return new URL(`video/${id}/`, `${access.url}:${access.port}/`).href;
}
async function getChannelCache() {
let cache = await browserType.storage.local.get('cache');
if (cache.cache) return cache;
return { cache: {} };
}
async function setChannel(channelHandler, channelId) {
let cache = await getChannelCache();
cache.cache[channelHandler] = { id: channelId, timestamp: Date.now() };
browserType.storage.local.set(cache);
}
async function getChannelId(channelHandle) {
let cache = await getChannelCache();
if (cache.cache[channelHandle]) {
return cache.cache[channelHandle]?.id;
}
let channel = await searchChannel(channelHandle);
if (channel) setChannel(channelHandle, channel.channel_id);
return channel.channel_id;
}
async function searchChannel(channelHandle) {
const path = `api/channel/search/?q=${channelHandle}`;
let response = await sendGet(path);
return response.data;
}
async function getChannel(channelHandle) {
let channelId = await getChannelId(channelHandle);
if (!channelId) return;
const path = `api/channel/${channelId}/`;
let response = await sendGet(path);
return response.data;
}
async function cookieStr(cookieLines) {
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');
}
async function sendCookies() {
console.log('function sendCookies');
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));
}
}
}
let response = cookieStr(cookieLines);
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;
}
}
})()
.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;
}
browserType.runtime.onMessage.addListener(handleMessage);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -14,33 +14,44 @@
<a href="#" id="ta-url" target="_blank">
<img src="/images/logo.png" alt="ta-logo">
</a>
<span>v0.0.2</span>
<span>v0.3.1</span>
</div>
<hr>
<div class="youtube-page" id="download"></div>
<form class="login-form">
<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="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">&#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>
</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://discord.gg/AFwz8nE7BK" target="_blank">
<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/tubearchivist" target="_blank">
<img src="/images/social/github.svg" alt="github-icon">
</a>
</div>
<div>
<a href="#">
<a href="https://github.com/tubearchivist/browser-extension/issues">
<img src="/images/question.svg" alt="question-icon">
</a>
</div>

View File

@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "TubeArchivist Companion",
"description": "Interact with your selfhosted TA server.",
"version": "0.0.2",
"version": "0.3.1",
"icons": {
"48": "/images/icon.png",
"128": "/images/icon128.png"
@ -11,7 +11,11 @@
"default_popup": "index.html"
},
"permissions": [
"storage"
"storage",
"cookies"
],
"host_permissions": [
"https://*.youtube.com/*"
],
"content_scripts": [
{

View File

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "TubeArchivist Companion",
"description": "Interact with your selfhosted TA server.",
"version": "0.0.2",
"version": "0.3.1",
"icons": {
"128": "/images/icon128.png"
},
@ -11,7 +11,9 @@
"default_popup": "index.html"
},
"permissions": [
"storage"
"storage",
"cookies",
"https://*.youtube.com/*"
],
"content_scripts": [
{

View File

@ -2,209 +2,256 @@
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;
}
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
return browser;
} else {
console.log("failed to dedect browser");
throw "browser detection error"
};
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 () {
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": document.getElementById("url").value,
"port": document.getElementById("port").value,
"apiKey": document.getElementById("api-key").value
}
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();
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();
});
// 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 cookie_validated = message.cookie_validated;
document.getElementById('sendCookiesStatus').innerText = 'validated: ' + cookie_validated;
}
function handleError(error) {
console.log(`Error: ${error}`);
setError(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);
}
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 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);
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);
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 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 == true) {
statusIcon.innerHTML = "&#9745;";
statusIcon.style.color = "green";
} else {
statusIcon.innerHTML = "&#9746;";
statusIcon.style.color = "red";
}
let statusIcon = document.getElementById('status-icon');
if (connected) {
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 () => {
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;
}
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 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 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("youtube", function(result) {
if (result.youtube) {
createButtons(result);
}
})
browserType.storage.local.get(['access', 'popupFullUrl', 'popupApiKey'], function (result) {
onGot(result);
});
})
browserType.storage.local.get('sendCookies', function (result) {
setCookiesOptions(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"));
}
browserType.storage.local.get('autostart', function (result) {
setAutostartOption(result);
});
});

View File

@ -2,74 +2,596 @@
content script running on youtube.com
*/
let browserType = getBrowser();
'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;
}
if (typeof chrome !== 'undefined') {
if (typeof browser !== 'undefined') {
console.log('detected firefox');
return browser;
} else {
console.log("failed to dedect browser");
throw "browser detection error"
};
console.log('detected chrome');
return chrome;
}
} else {
console.log('failed to dedect browser');
throw 'browser detection error';
}
}
function getChannelContainers() {
const elements = document.querySelectorAll('.yt-flexible-actions-view-model-wiz, #owner');
const channelContainerNodes = [];
function detectUrlType(url) {
const videoRe = new RegExp(/^https:\/\/(www\.)?(youtube.com\/watch\?v=|youtu\.be\/)\w{11}/);
if (videoRe.test(url)) {
return "video"
}
const channelRe = new RegExp(/^https:?\/\/www\.?youtube.com\/c|channel|user\/\w+(\/|featured|videos)?$/);
if (channelRe.test(url)) {
return "channel"
}
const playlistRe = new RegExp(/^https:\/\/(www\.)?youtube.com\/playlist\?list=/);
if (playlistRe.test(url)) {
return "playlist"
elements.forEach(element => {
if (isElementVisible(element)) {
channelContainerNodes.push(element);
}
});
return false
return channelContainerNodes;
}
function isElementVisible(element) {
return element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
}
function sendUrl() {
function ensureTALinks() {
let channelContainerNodes = getChannelContainers();
let url = document.URL
for (let channelContainer of channelContainerNodes) {
channelContainer = adjustOwner(channelContainer);
if (channelContainer.hasTA) continue;
let channelButton = buildChannelButton(channelContainer);
channelContainer.appendChild(channelButton);
channelContainer.hasTA = true;
}
let urlType = detectUrlType(url);
if (urlType == false) {
console.log("not relevant")
return
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;
}
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);
channelContainer.taObserver = new MutationObserver(throttled(updateButtonsIfNecessary, 100));
channelContainer.taObserver.observe(channelContainer, {
attributes: true,
childList: true,
subtree: true,
});
}
};
return buttonDiv;
}
function getChannelHandle(channelContainer) {
let channelHandle;
const videoOwnerRenderer = channelContainer.querySelector('.ytd-video-owner-renderer');
document.addEventListener("yt-navigate-finish", function (event) {
setTimeout(function(){
sendUrl();
return false;
if (!videoOwnerRenderer) {
const channelHandleContainer = document.querySelector(
'.yt-content-metadata-view-model-wiz__metadata-text'
);
channelHandle = channelHandleContainer ? channelHandleContainer.innerText : null;
} else {
const href = videoOwnerRenderer.href;
if (href) {
const urlObj = new URL(href);
channelHandle = urlObj.pathname.split('/')[1];
}
}
return channelHandle;
}
function buildChannelButtonDiv() {
let buttonDiv = document.createElement('div');
buttonDiv.classList.add('ta-channel-button');
Object.assign(buttonDiv.style, {
display: 'flex',
alignItems: 'center',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '14px',
padding: '5px',
'margin-left': '8px',
borderRadius: '18px',
});
return buttonDiv;
}
function buildChannelSubButton(channelHandle) {
let channelSubButton = document.createElement('span');
channelSubButton.innerText = 'Checking...';
channelSubButton.title = `TA Subscribe: ${channelHandle}`;
channelSubButton.setAttribute('data-id', channelHandle);
channelSubButton.setAttribute('data-type', 'channel');
channelSubButton.addEventListener('click', e => {
e.preventDefault();
if (channelSubButton.innerText === 'Subscribe') {
console.log(`subscribe to: ${channelHandle}`);
sendUrl(channelHandle, 'subscribe', channelSubButton);
} else if (channelSubButton.innerText === 'Unsubscribe') {
console.log(`unsubscribe from: ${channelHandle}`);
sendUrl(channelHandle, 'unsubscribe', channelSubButton);
} else {
console.log('Unknown state');
}
});
Object.assign(channelSubButton.style, {
padding: '5px',
cursor: 'pointer',
});
checkChannelSubscribed(channelSubButton);
return channelSubButton;
}
function checkChannelSubscribed(channelSubButton) {
function handleResponse(message) {
if (!message || (typeof message === 'object' && message.channel_subscribed === false)) {
channelSubButton.innerText = 'Subscribe';
} else if (typeof message === 'object' && message.channel_subscribed === true) {
channelSubButton.innerText = 'Unsubscribe';
} else {
console.log('Unknown state');
}
}
function handleError(e) {
buttonError(channelSubButton);
channelSubButton.innerText = 'Error';
console.error('error', e);
}
let channelHandle = channelSubButton.dataset.id;
let message = { type: 'getChannel', channelHandle };
let sending = sendMessage(message);
sending.then(handleResponse, handleError);
}
function buildSpacer() {
let spacer = document.createElement('span');
spacer.innerText = '|';
return spacer;
}
function buildChannelDownloadButton() {
let channelDownloadButton = document.createElement('span');
let currentLocation = window.location.href;
let urlObj = new URL(currentLocation);
if (urlObj.pathname.startsWith('/watch')) {
let params = new URLSearchParams(document.location.search);
let videoId = params.get('v');
channelDownloadButton.setAttribute('data-type', 'video');
channelDownloadButton.setAttribute('data-id', videoId);
channelDownloadButton.title = `TA download video: ${videoId}`;
checkVideoExists(channelDownloadButton);
} else {
channelDownloadButton.setAttribute('data-id', currentLocation);
channelDownloadButton.setAttribute('data-type', 'channel');
channelDownloadButton.title = `TA download channel ${currentLocation}`;
}
channelDownloadButton.innerHTML = downloadIcon;
channelDownloadButton.addEventListener('click', e => {
e.preventDefault();
console.log(`download: ${currentLocation}`);
sendDownload(channelDownloadButton);
});
Object.assign(channelDownloadButton.style, {
filter: 'invert()',
width: '20px',
padding: '0 5px',
cursor: 'pointer',
});
return channelDownloadButton;
}
function getTitleContainers() {
let elements = document.querySelectorAll('#video-title');
let videoNodes = [];
elements.forEach(element => {
if (isElementVisible(element)) {
videoNodes.push(element);
}
});
return elements;
}
function getVideoId(titleContainer) {
if (!titleContainer) return undefined;
let href = getNearestLink(titleContainer);
if (!href) return;
let videoId;
if (href.startsWith('/watch?v')) {
let params = new URLSearchParams(href);
videoId = params.get('/watch?v');
} else if (href.startsWith('/shorts/')) {
videoId = href.split('/')[2];
}
return videoId;
}
function buildVideoButton(titleContainer) {
let videoId = getVideoId(titleContainer);
if (!videoId) return;
const dlButton = document.createElement('a');
dlButton.classList.add('ta-button');
dlButton.href = '#';
Object.assign(dlButton.style, {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#00202f',
color: '#fff',
fontSize: '1.4rem',
textDecoration: 'none',
borderRadius: '8px',
cursor: 'pointer',
height: 'fit-content',
opacity: 0,
});
let dlIcon = document.createElement('span');
dlIcon.innerHTML = defaultIcon;
Object.assign(dlIcon.style, {
filter: 'invert()',
width: '15px',
height: '15px',
padding: '7px 8px',
});
dlButton.appendChild(dlIcon);
dlButton.addEventListener('click', e => {
e.preventDefault();
sendDownload(dlButton);
e.stopPropagation();
});
return dlButton;
}
function getNearestLink(element) {
// Check siblings
let sibling = element;
while (sibling) {
sibling = sibling.previousElementSibling;
if (sibling && sibling.tagName === 'A' && sibling.getAttribute('href') !== '#') {
return sibling.getAttribute('href');
}
}
sibling = element;
while (sibling) {
sibling = sibling.nextElementSibling;
if (sibling && sibling.tagName === 'A' && sibling.getAttribute('href') !== '#') {
return sibling.getAttribute('href');
}
}
// Check parent elements
for (let i = 0; i < 5 && element && element !== document; i++) {
if (element.tagName === 'A' && element.getAttribute('href') !== '#') {
return element.getAttribute('href');
}
element = element.parentNode;
}
return null;
}
function getNearestH3(element) {
for (let i = 0; i < 5 && element && element !== document; i++) {
if (element.tagName === 'H3') {
return element;
}
element = element.parentNode;
}
return null;
}
function processTitle(titleContainer) {
if (titleContainer.hasListener) return;
Object.assign(titleContainer.style, {
display: 'flex',
gap: '15px',
});
titleContainer.classList.add('title-container');
titleContainer.addEventListener('mouseenter', () => {
const taButton = titleContainer.querySelector('.ta-button');
if (!taButton) return;
if (!taButton.isChecked) checkVideoExists(taButton);
taButton.style.opacity = 1;
});
titleContainer.addEventListener('mouseleave', () => {
const taButton = titleContainer.querySelector('.ta-button');
if (!taButton) return;
taButton.style.opacity = 0;
});
titleContainer.hasListener = true;
}
function checkVideoExists(taButton) {
function handleResponse(message) {
let buttonSpan = taButton.querySelector('span') || taButton;
if (message !== false) {
buttonSpan.innerHTML = checkmarkIcon;
buttonSpan.title = 'Open in TA';
buttonSpan.addEventListener('click', () => {
let win = window.open(message, '_blank');
win.focus();
});
} else {
buttonSpan.innerHTML = downloadIcon;
}
taButton.isChecked = true;
}
function handleError(e) {
buttonError(taButton);
let videoId = taButton.dataset.id;
console.log(`error: failed to get info from TA for video ${videoId}`);
console.error(e);
}
let videoId = taButton.dataset.id;
if (!videoId) {
videoId = getVideoId(taButton);
if (videoId) {
taButton.setAttribute('data-id', videoId);
taButton.setAttribute('data-type', 'video');
taButton.title = `TA download video: ${taButton.parentElement.innerText} [${videoId}]`;
}
}
let message = { type: 'videoExists', videoId };
let sending = sendMessage(message);
sending.then(handleResponse, handleError);
}
function sendDownload(button) {
let url = button.dataset.id;
if (!url) return;
sendUrl(url, 'download', button);
}
function buttonError(button) {
let buttonSpan = button.querySelector('span');
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 });

View File

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

1960
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"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
}
}