Compare commits

...

175 commits

Author SHA1 Message Date
github-actions[bot]
60735f9507
docs(README): update contributors 2023-10-12 20:22:54 +00:00
kodxana
8c26ae0872
Merge pull request #139 from DeepDoge/master
Little clean up and fixes
2022-10-06 19:01:02 +02:00
Shiba
6ba1a373a9
🍣 max-width on popup removed
was causing some issues with exotic browsers
2022-10-01 12:18:34 +03:00
Shiba
c1b53d90a1
Merge branch 'LBRYFoundation:master' into master 2022-09-28 14:39:34 +03:00
Shiba
529931c43b 🍙 removed console.log s 2022-09-28 14:38:37 +03:00
kodxana
351ca5c6c3
Up Version 2022-09-02 13:19:07 +02:00
Shiba
ca65a0288d 🍣 some little fixes in the code
- nothing changed, just some little fixes
2022-08-10 10:18:04 +00:00
kodxana
309806605f
Merge pull request #134 from DeepDoge/master
Major changes
2022-08-10 11:50:01 +02:00
Shiba
7fa57b9e88 🍙 updated YTtoLBRY video 2022-08-10 09:38:57 +00:00
Shiba
21f6ffc85b 🍤 popup width made larger 2022-08-10 08:17:55 +00:00
Shiba
1ee722b6ff 🍣 improved logic of redrect and opening a new tab 2022-08-10 08:12:04 +00:00
Shiba
28ef256c40 🍙 on redirect pause the video directly only 2022-08-10 07:56:24 +00:00
Shiba
a41b51e454 🍣 handle unexpected location.replace behavior 2022-08-09 22:44:40 +00:00
Shiba
5c9910dff1 Merge branch 'master' of https://github.com/DeepDoge/Watch-on-LBRY 2022-08-09 22:22:34 +00:00
Shiba
59a5e0dee2 🍙 on redirect use replace if tab has no history 2022-08-09 22:22:25 +00:00
Shiba
e4fdc4e831
Merge branch 'LBRYFoundation:master' into master 2022-08-10 00:17:05 +03:00
Shiba
9c26d553f5 🍣 major changes
- modes are removed
- added more options for redirect
- extension badge removed
2022-08-09 21:16:05 +00:00
kodxana
f9c70dd90d
Merge pull request #133 from DeepDoge/master
Bug fixes again
2022-08-09 23:15:47 +02:00
Shiba
9ed962df96 Merge branch 'master' of https://github.com/DeepDoge/Watch-on-LBRY 2022-08-09 19:10:43 +00:00
Shiba
a95b46bf0c 🍙 made url resovler timeout longer 2022-08-09 19:10:37 +00:00
Shiba
913282290e
Merge branch 'LBRYFoundation:master' into master 2022-08-09 22:00:41 +03:00
Shiba
6af354de0c 🍙 more bug fixes 2022-08-09 17:49:16 +00:00
Shiba
15f03b3bcc 🍙 removed debug logs 2022-08-09 17:06:41 +00:00
kodxana
64570b83bf
Merge pull request #132 from DeepDoge/master
Opening new tabs handled by the background now
2022-08-09 18:58:29 +02:00
Shiba
4295dbe1fe 🍣 bug fixes 2022-08-09 16:49:45 +00:00
Shiba
619d66d6aa Merge branch 'master' of https://github.com/DeepDoge/Watch-on-LBRY 2022-08-09 16:42:58 +00:00
Shiba
b26d540a4a 🍣 bug fixes 2022-08-09 16:42:51 +00:00
Shiba
32290e5f63
Merge branch 'LBRYFoundation:master' into master 2022-08-09 13:32:27 +03:00
Shiba
c089de3f0f Merge branch 'master' of https://github.com/DeepDoge/Watch-on-LBRY 2022-08-09 10:31:55 +00:00
Shiba
fcdc5cb77a 🍙 opening new tabs handled by the background now
- because firefox seem to block content script from openning new tabs in some cases
2022-08-09 10:31:48 +00:00
kodxana
e0d514916e
Merge pull request #131 from DeepDoge/master
Added option for where to show the buttons
2022-08-08 00:00:17 +02:00
Shiba
376c7b185a
Merge branch 'LBRYFoundation:master' into master 2022-08-07 13:30:46 +03:00
Shiba
73147fdbcd Merge branch 'master' of https://github.com/DeepDoge/Watch-on-LBRY 2022-08-07 10:30:14 +00:00
Shiba
9edd318a35 🍙 added option for where to show the buttons 2022-08-07 10:30:06 +00:00
kodxana
f5c0fc450b
Merge pull request #130 from DeepDoge/master
Removed `tabs` permission
2022-08-06 22:24:43 +02:00
Shiba
259582cd3e
Merge branch 'LBRYFoundation:master' into master 2022-08-06 21:05:33 +03:00
Shiba
38f2d42404 removed tabs permission 2022-08-06 18:04:45 +00:00
kodxana
7b585b27a9
Merge pull request #129 from DeepDoge/master
Added timeout to resolver API request
2022-07-28 14:08:01 +02:00
Shiba
6721966e5b 🍙 added timeout to resolver api request 2022-07-27 06:43:58 +00:00
kodxana
f087d0d1e5
Merge pull request #128 from DeepDoge/master
Button changes
2022-07-26 10:59:34 +02:00
Shiba
de43b990f2 🍣 button changes 2022-07-25 23:25:33 +00:00
Shiba
8f88dbebe7 🍙 little bug fix 2022-07-25 21:44:14 +00:00
kodxana
0aa64bb8b2
Merge pull request #127 from DeepDoge/master
Watch on LBRY button also added to the Video player + Some bug fixes
2022-07-25 22:47:40 +02:00
Shiba
f4dd0306fc
Merge branch 'LBRYFoundation:master' into master 2022-07-25 10:41:07 +03:00
Shiba
29c9b2829a 🍣 New button on the video player 2022-07-24 23:18:34 +00:00
Shiba
a6472799a1 🍙 Bug fixes, and other fixes 2022-07-24 20:41:51 +00:00
kodxana
1f29fc7c29
Merge pull request #125 from DeepDoge/master
Made yt video pause, since we use new tab now
2022-07-08 23:24:19 +02:00
Shiba
ca4c486ea7 Merge branch 'master' of https://github.com/DeepDoge/Watch-on-LBRY 2022-07-07 18:00:38 +00:00
Shiba
4102177212 🍣 Some more bug fixes 2022-07-07 18:00:32 +00:00
Shiba
da8dee83f9
Merge branch 'LBRYFoundation:master' into master 2022-07-07 20:43:33 +03:00
Shiba
ce11d4fdf3 🍣 little bug fix 2022-07-07 17:37:57 +00:00
Shiba
1eb21eb51f 🍙 made yt video pause, since we use new tab now 2022-07-06 13:10:32 +00:00
kodxana
a0577b4530
Merge pull request #123 from DeepDoge/master
New stuff and some changes
2022-07-02 17:33:17 +02:00
Shiba
c895d53253 🍙 new stuff and some changes
- channel buttons and redirect
- in button mode if there is no target try to find lbry url in the description
- used a loop in content script instead of events and stuff to make it less confusing
2022-07-02 15:15:36 +00:00
kodxana
66cb8ccccf
Merge pull request #122 from DeepDoge/master
Some changes
2022-07-01 23:13:02 +02:00
Shiba
f072c95051 🍤 Build for manifest v2 and v3 at once
- and zip them
2022-07-01 20:39:13 +00:00
Shiba
0ba4efd43e 🍙 New button style with gradient 2022-07-01 20:37:03 +00:00
Shiba
066c400cce 🍣 Bug fixes and small changes 2022-07-01 20:34:53 +00:00
kodxana
63a251c0ae
Merge pull request #120 from DeepDoge/master
💩 Upgraded to manifest v3
2022-05-02 17:11:36 +02:00
Shiba
be310e6cb1 🧀 added manifest v2 support for firefox 2022-05-02 13:17:58 +00:00
Shiba
cebdf480fe 💩 upgraded to manifest v3 2022-05-02 12:12:11 +00:00
kodxana
7076a657fc
Update main.tsx 2022-05-02 11:49:00 +02:00
kodxana
dfa1fd03a8
Merge pull request #118 from DeepDoge/master
Bugfixes
2022-05-02 10:01:13 +02:00
Shiba
cddd415e22 🍣 more bugfixes 2022-05-02 01:09:11 +00:00
Shiba
6aa507c53d 🍙 custom dialogs added 2022-05-01 21:20:59 +00:00
Shiba
0bcf69e99b 🍜 bugfixes 2022-05-01 16:55:13 +00:00
kodxana
ccf6d2d990
Update README.md 2022-05-01 18:10:21 +02:00
kodxana
c4231c29d6
Update README.md 2022-05-01 18:09:02 +02:00
kodxana
cf85b39d6a
Merge pull request #116 from DeepDoge/master
Organized Files, Remade YTtoLBRY page, And some refactoring
2022-05-01 17:51:53 +02:00
Shiba
5d9d0416a3 🔥 Organized Files
- And some refactoring
2022-05-01 15:49:55 +00:00
Shiba
e2e7426b5b 🍘 YTtoLBRY tool has been remade 2022-05-01 14:38:54 +00:00
kodxana
6b8f193bfd
Merge pull request #115 from DeepDoge/master
Bug Fixes
2022-05-01 16:38:50 +02:00
Shiba
c2f894ec61
🍙 bugfix 2022-05-01 12:25:54 +03:00
Shiba
ce40824a0c 🍘 bugfix, filereader but on firefox 2022-04-30 23:33:19 +00:00
Shiba
f86affc093 🍣 crypto changes 2022-04-30 23:25:30 +00:00
Shiba
deed10423a 🍙 added back button to import/generate profile view 2022-04-30 18:35:00 +00:00
Shiba
caa0d196ed 🍣 bug fixes 2022-04-30 17:48:11 +00:00
kodxana
a27d5a285b
Merge pull request #114 from DeepDoge/master
Refactoring and Fixes
2022-04-30 18:17:58 +02:00
Shiba
e8d7a349f4 🍙 refactoring and fixes 2022-04-30 16:14:52 +00:00
kodxana
fe5c38bfed
Update manifest.json 2022-04-30 15:24:41 +02:00
kodxana
53eb4a0c8e
Merge pull request #113 from DeepDoge/master
New UI and Madiator Finder Features
2022-04-30 15:23:51 +02:00
Shiba
f1f4c335e9 🔥 New UI and Madiator Finder Features 2022-04-30 12:49:14 +00:00
Shiba
3ee7e530d6 🔥 New UI and Madiator Finder Features 2022-04-30 12:23:03 +00:00
Shiba
81f1742289 🍙 updating UI 2022-04-22 20:42:57 +00:00
Shiba
a0f66bc062 🍣 implementing finder login 2022-04-22 19:08:03 +00:00
Shiba
450ca8cef6 🔥 bug fixes 2022-04-15 13:37:37 +00:00
Shiba
651ca5b4dc 🔥 debug code removed 2022-04-15 10:30:49 +00:00
Shiba
2a3771e45a 🍙 signature fixes 2022-04-15 09:50:25 +00:00
Shiba
365173d316 🍙 auth added 2022-04-15 09:37:39 +00:00
Shiba
3e60ed295f 🍣 removed scrap and simplified the urlResolver 2022-04-14 20:58:47 +00:00
kodxana
8c5e2d68e0
Merge pull request #107 from DeepDoge/patch-2
Changed QUERY_CHUNK_SIZE
2022-03-14 16:24:04 +01:00
Shiba
222a71948b
🍙 changed QUERY_CHUNK_SIZE
- odysee api started to give 520 response with the previous size
2022-03-14 18:10:53 +03:00
kodxana
bd33aa833f
API CORS fix + Up Version 2022-03-04 19:43:05 +01:00
kodxana
3edc78c36a
Up version 2022-02-02 23:01:03 +01:00
kodxana
27207e0f9c
Merge pull request #101 from DeepDoge/master
Button wasn't being updated when we change target platform
2022-01-27 21:10:14 +01:00
Shiba
3692ccba89 ... 2022-01-27 11:24:35 +00:00
Shiba
bb0dc3c4f4 🍘 bug fixes
- lbry-url parser disabled because it was returning empty array and it just works without it
- content script wasnt starting to work
- background error on content script was being handled wrong
- background lbryPathnameFromVideoId wasnt ending well on error
2022-01-27 11:17:00 +00:00
Shiba
b17f2f4e2e 🍙 updater wasn't working on start 2022-01-26 18:22:43 +00:00
kodxana
4a23670e4d
Merge pull request #100 from DeepDoge/master
Browser's uri dialog was causing confusion sometimes
2022-01-25 23:10:31 +01:00
Shiba
92fbdd727d 🍘 updater wasn't working on settings change 2022-01-25 16:42:19 +00:00
Shiba
6ef5459d7a 🍙 browser's uri dialog was causing confusion sometimes 2022-01-25 16:24:19 +00:00
kodxana
e501a8b828
Merge pull request #99 from DeepDoge/master
On error background returns undefined but it should throw
2022-01-25 17:07:39 +01:00
Shiba
324d3800de 🍱 On error background returns undefined but it should throw 2022-01-24 08:34:41 +00:00
kodxana
e01a3990c8
Merge pull request #93 from DeepDoge/master
Madiator scrap API version changed to 2
2022-01-15 17:27:18 +01:00
Shiba
79ba91a1f1 🥡 set madiator scrap api version to 2 2022-01-14 18:36:18 +00:00
kodxana
7f3f8919f0
Merge pull request #91 from DeepDoge/master
Added .devcontainer
2022-01-11 21:53:36 +01:00
kodxana
c94ec17fa3
Merge pull request #92 from DeepDoge/patch-1
Changed cache time
2022-01-11 21:53:24 +01:00
Shiba
585a232021 🍘 changed cache time for lbrypathnames 2022-01-11 22:29:28 +03:00
Shiba
2b38c809ca 🥡 added .devcontainer
- to make building the project easier in vscode
- and consistent file formatting in vscode
2022-01-11 13:45:24 +00:00
kodxana
093deaa8fd
Merge pull request #90 from DeepDoge/1.7.6
Added support for new invidious instances
2022-01-10 18:13:36 +01:00
Shiba
fde3d7b897 🍣 added support for new invidious nodes
- vid.puffyan.us
- invidio.xamh.de
- invidious.kavin.rocks
2022-01-10 17:05:02 +00:00
kodxana
bccbce8db1
Merge pull request #89 from DeepDoge/1.7.6
Clear cache button added and caching improved
2022-01-10 17:55:16 +01:00
github-actions[bot]
766b6cf990
docs(README): update contributors 2022-01-10 16:37:14 +00:00
kodxana
889c64c0a6
Merge pull request #88 from DeepDoge/1.7.6
Refactor and bug fixes
2022-01-10 17:36:37 +01:00
Shiba
89ce1aeb31 🍙 clear cache button added 2022-01-10 16:34:01 +00:00
Shiba
3a07d7f82b 🍙 not checking for cache expiry only at start now 2022-01-10 16:33:41 +00:00
Shiba
4653f77be4 🍙 Redirect bug fix
- location.replace was running for the app too
2022-01-10 15:33:07 +00:00
Shiba
94cda17b99 🍙 Renamed LBRY Inc. to Odysee 2022-01-10 15:29:50 +00:00
Shiba
2c75082af9 🍣 Formatted Files, Organized Imports 2022-01-10 12:36:29 +00:00
Shiba
8f75c67601 🥡 Refactor 2022-01-10 12:32:32 +00:00
Shiba
5bcd33890d 🍘 made video pause on all redirects not only app 2022-01-10 00:39:28 +00:00
Shiba
d32958852a 🍙 bugfix redirect to lbryapp 2022-01-10 00:22:20 +00:00
Shiba
5396936586 🍣 typo fixes 2022-01-10 00:09:15 +00:00
Shiba
06524954e7 🥞 redirect bugfix 2022-01-10 00:08:10 +00:00
Shiba
86879183b2 🍱 Refactor bug fix 2022-01-09 23:17:20 +00:00
kodxana
30b65454ff
Merge pull request #87 from DeepDoge/1.7.6 2022-01-10 00:15:18 +01:00
Shiba
30f077ba38 🍙 Refactor and bugfix 2022-01-09 23:01:04 +00:00
Shiba
610b47d1e4 🍘 Refactor bugfix 2022-01-09 22:03:31 +00:00
Shiba
2b91436900 🥡 Refactor 2022-01-09 21:11:58 +00:00
Shiba
4f8e807a65 🍱 Refactor 2022-01-09 21:04:03 +00:00
Shiba
cb4b4f4b2e 🥞 Refactor 2022-01-09 19:10:27 +00:00
Shiba
75cb9cf01d 🍘 Refactor 2022-01-09 19:01:01 +00:00
Shiba
7727d04157 🥡 Refactor 2022-01-09 18:59:41 +00:00
Shiba
719ff06caf 🍣 Refactor 2022-01-09 18:54:28 +00:00
Shiba
4cdcc4c9a4 🍱 Refactor, removed not used backend code. bugfixes 2022-01-09 18:43:46 +00:00
Shiba
205a8fd151 🍙 Refactor removed pick from getExtensionSettings 2022-01-09 18:42:35 +00:00
Shiba
1e7293826a 🍙 Refactor, moved many backend stuff to content 2022-01-09 18:41:30 +00:00
Shiba
b750c86b88 🍘 ctxFromURL runs once for the same url at a time 2022-01-09 13:12:05 +00:00
Shiba
475c38ba0c 🍙 URL resolver caching improvements
- Updated types for url because it can also be null
- Cached 404 as null
2022-01-09 12:46:18 +00:00
Shiba
ff83dfc62d 🍱 Removed logs 2022-01-09 12:20:24 +00:00
Shiba
685f9615ca 🍱 URL resolver api caching handled by the client
- Told browser to not to cache the api request
- Removed manual memory cache
- Implimented caching using indexedDB
- Cache time is 1 day
2022-01-09 11:54:53 +00:00
Shiba
73508ff532 🍣 added back memory cache temporarily
- added back manual memory cache temporarily
- and this time its working as expected
- also disabled use of browser cache for now
- thought we are still saying browser to cache it for the future version
2022-01-08 23:32:49 +00:00
kodxana
70ad28ba22
Up version 2022-01-07 23:29:40 +01:00
kodxana
a0b6182a96
Merge pull request #86 from LBRYFoundation/1.7.6
1.7.6
2022-01-07 23:09:09 +01:00
kodxana
6597acd0a5
Merge pull request #85 from DeepDoge/1.7.6
Removed memory cache for ctxFromURL
2022-01-07 23:08:30 +01:00
Shiba
c2aacaf307 🍘 removed memory cache for ctxFromURL
- we already tell browser to cache the request
- and we dont need the response in the same call stack
2022-01-07 22:01:30 +00:00
kodxana
c24fd17013
Merge pull request #84 from LBRYFoundation/1.7.6
1.7.6
2022-01-07 22:01:18 +01:00
kodxana
a7b66660ea
Merge pull request #83 from DeepDoge/1.7.6
[Feature Update] Timestamp feature made more smart
2022-01-07 21:58:18 +01:00
Shiba
7c50daf7fc 🥞 timestamp feature made more smart
- if time is smaller than 3 dont add timestamp
- if the video ended dont add timestamp
2022-01-07 20:56:56 +00:00
kodxana
a671df51a0
Merge pull request #82 from LBRYFoundation/1.7.6
1.7.6
2022-01-07 21:55:12 +01:00
kodxana
668018d89d
Merge pull request #81 from DeepDoge/1.7.6
[BUG FIX]  ctxFromUrl pathname check fixed
2022-01-07 21:54:43 +01:00
Shiba
2b3d43e0dc 🥡bug fix
- ctxFromUrl pathname check fixed
2022-01-07 20:40:36 +00:00
kodxana
19967c5ecc
Merge pull request #80 from LBRYFoundation/1.7.6
1.7.6
2022-01-07 21:14:15 +01:00
github-actions[bot]
2352237bf5
docs(README): update contributors 2022-01-07 20:13:21 +00:00
kodxana
00f2bb82f8
Merge pull request #79 from DeepDoge/1.7.6
URL resolver changes
2022-01-07 21:13:04 +01:00
kodxana
77d95ab61c
Merge pull request #78 from LBRYFoundation/1.7.6
1.7.6
2022-01-07 21:12:53 +01:00
Shiba
75274005ee 🍙 url resolver changes
- made it send more than one request at once
2022-01-07 20:09:05 +00:00
kodxana
682af767c9
Merge pull request #77 from DeepDoge/patch
[Feature] Madiator.com URL resolver added
2022-01-07 20:11:49 +01:00
Shiba
9b68487ecc 🍣 madiator url resolver added
- multiple resolver feature added
- added progress bar to sub converter
- csv sub converter bug fix
2022-01-07 19:00:19 +00:00
kodxana
336e3050f0
Merge pull request #76 from DeepDoge/patch
Updated variable name
2021-12-28 18:34:19 +01:00
Shiba
6b4a377058 Updated variable name
- source/target platfrom setthing variable and the type had the same name
2021-12-27 22:36:17 +00:00
kodxana
279f7faff7
Merge pull request #75 from DeepDoge/yewtube-added
[Feature] Yewtube support added
2021-12-19 14:36:28 +01:00
Shiba
8f30825edd Updated Watch On LBRY button style 2021-12-19 11:59:36 +00:00
Shiba
4ab8fdce5e Updated Watch On LBRY button 2021-12-19 11:46:33 +00:00
Shiba
58e6c119f4 Updated youtube button mount point query 2021-12-19 11:12:44 +00:00
Shiba
ba5beca60a Changed button mount point for yewtube 2021-12-19 01:58:45 +03:00
Shiba
dd16ce1041 Variable name typo fixed 2021-12-19 01:58:14 +03:00
Shiba
63d91a2ee1 Updated styles to use em instead of px 2021-12-19 01:57:29 +03:00
Shiba
4220ea11c7 Platform name 'App changed to 'LBRY App' 2021-12-19 01:57:02 +03:00
Shiba
47d27b0671 Added yewtu.be support. And name changes
- Added settings for source platform
- Renamed platform as target platform
2021-12-19 01:56:40 +03:00
github-actions[bot]
b17a7020fd
docs(README): update contributors 2021-12-14 08:22:37 +00:00
kodxana
3bab9d15ee
Update contributors.yml 2021-12-14 09:22:08 +01:00
kodxana
adab9addf5
Update README.md 2021-12-14 09:21:23 +01:00
kodxana
f3cf3526f5
Create contributors.yml 2021-12-14 09:18:46 +01:00
kodxana
3b4bc7910f
Create CHANGELOG.md 2021-12-12 10:12:50 +01:00
65 changed files with 19787 additions and 974 deletions

16
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node/.devcontainer/base.Dockerfile
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster
ARG VARIANT="16-bullseye"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node packages
# RUN su node -c "npm install -g <your-package-list -here>"

View file

@ -0,0 +1,36 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/typescript-node
{
"name": "Node.js & TypeScript",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 16, 14, 12.
// Append -bullseye or -buster to pin to an OS version.
// Use -bullseye variants on local on arm64/Apple Silicon.
"args": {
"VARIANT": "14-bullseye"
}
},
// Set *default* container specific settings.json values on container create.
"settings": {
"javascript.format.placeOpenBraceOnNewLineForControlBlocks": false,
"javascript.format.placeOpenBraceOnNewLineForFunctions": false,
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
"typescript.format.placeOpenBraceOnNewLineForFunctions": false
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"bierner.folder-source-actions",
"jbockle.jbockle-format-files",
"eamodio.gitlens"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "npm install",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node",
"features": {
"python": "latest"
}
}

23
.github/workflows/contributors.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Add contributors
on:
schedule:
- cron: '20 20 * * *'
push:
branches:
- master
jobs:
add-contributors:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: BobAnkh/add-contributors@master
with:
CONTRIBUTOR: '## Contributors'
COLUMN_PER_ROW: '6'
ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}}
IMG_WIDTH: '100'
FONT_SIZE: '14'
PATH: '/README.md'
COMMIT_MESSAGE: 'docs(README): update contributors'
AVATAR_SHAPE: 'round'

2
.gitignore vendored
View file

@ -1,8 +1,8 @@
.cache
dist
build
node_modules
web-ext-artifacts
yarn-error.log
.devcontainer
.DS_Store

15
CHANGELOG.md Normal file
View file

@ -0,0 +1,15 @@
# Changelog
## [1.7.5](https://github.com/LBRYFoundation/Watch-on-LBRY/releases/tag/1.7.5) (2021-12-12)
**Closed issues:**
- Subscription Converter feature doesn't seem to work [\#64](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/64)
- Redirect with timestamp [\#57](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/57)
- Should default to odysee.com instead of lbry.tv [\#53](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/53)
- Instead of redirect. Show a popup from the extension icon [\#38](https://github.com/LBRYFoundation/Watch-on-LBRY/issues/38)
**Merged pull requests:**
- Update ytContent.tsx [\#70](https://github.com/LBRYFoundation/Watch-on-LBRY/pull/70) ([Shiba](https://github.com/DeepDoge))
- Madiator icon added [\#71](https://github.com/LBRYFoundation/Watch-on-LBRY/pull/71) ([Shiba](https://github.com/DeepDoge))

View file

@ -1,5 +1,5 @@
## Looking for contributors :)
![Logo](src/icons/wol/default-transparent.svg)
![Logo](src/assets/icons/wol/default-transparent.svg)
# Watch on LBRY
A plugin for web browsers that brings more utility for LBRY Protocol by allowing you to find people you watch on YouTube that are availible on LBRY.tv/Odysee/Desktop App and other LBRY Protocol based apps/websites, allows you to easly check your subscribtion list and much more!
@ -62,6 +62,85 @@ Pull requests are welcome. For major changes, please open an issue first to disc
Please make sure to update tests as appropriate.
## Contributors
<table>
<tr>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/kodxana>
<img src=https://avatars.githubusercontent.com/u/16674412?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=kodxana/>
<br />
<sub style="font-size:14px"><b>kodxana</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/DeepDoge>
<img src=https://avatars.githubusercontent.com/u/44804845?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Shiba/>
<br />
<sub style="font-size:14px"><b>Shiba</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/Aenigma>
<img src=https://avatars.githubusercontent.com/u/409173?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kevin Raoofi/>
<br />
<sub style="font-size:14px"><b>Kevin Raoofi</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/Yamboy1>
<img src=https://avatars.githubusercontent.com/u/37413895?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Yamboy1/>
<br />
<sub style="font-size:14px"><b>Yamboy1</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/clay53>
<img src=https://avatars.githubusercontent.com/u/16981283?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Clayton Hickey/>
<br />
<sub style="font-size:14px"><b>Clayton Hickey</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/adam-dorin>
<img src=https://avatars.githubusercontent.com/u/1072815?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Adam/>
<br />
<sub style="font-size:14px"><b>Adam</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/kbakdev>
<img src=https://avatars.githubusercontent.com/u/56700396?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kacper Bąk/>
<br />
<sub style="font-size:14px"><b>Kacper Bąk</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/eggplantbren>
<img src=https://avatars.githubusercontent.com/u/1578298?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Brendon J. Brewer/>
<br />
<sub style="font-size:14px"><b>Brendon J. Brewer</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/FireMasterK>
<img src=https://avatars.githubusercontent.com/u/20838718?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Kavin/>
<br />
<sub style="font-size:14px"><b>Kavin</b></sub>
</a>
</td>
<td align="center" style="word-wrap: break-word; width: 150.0; height: 150.0">
<a href=https://github.com/kauffj>
<img src=https://avatars.githubusercontent.com/u/530774?v=4 width="100;" style="border-radius:50%;align-items:center;justify-content:center;overflow:hidden;padding-top:10px" alt=Jeremy Kauffman/>
<br />
<sub style="font-size:14px"><b>Jeremy Kauffman</b></sub>
</a>
</td>
</tr>
</table>
## License
[GPL-3.0 License](LICENSE)

9
global.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
declare module '*.md' {
var _: string
export default _
}
declare namespace chrome
{
export const action = chrome.browserAction
}

56
manifest.v2.json Normal file
View file

@ -0,0 +1,56 @@
{
"manifest_version": 2,
"name": "Watch on LBRY",
"version": "2.0.1",
"icons": {
"16": "assets/icons/wol/icon16.png",
"48": "assets/icons/wol/icon48.png",
"128": "assets/icons/wol/icon128.png"
},
"permissions": [
"https://www.youtube.com/",
"https://yewtu.be/",
"https://vid.puffyan.us/",
"https://invidio.xamh.de/",
"https://invidious.kavin.rocks/",
"https://api.odysee.com/",
"https://lbry.tv/",
"https://odysee.com/",
"https://madiator.com/",
"https://finder.madiator.com/",
"tabs",
"storage"
],
"web_accessible_resources": [
"pages/popup/index.html",
"pages/YTtoLBRY/index.html",
"pages/import/index.html",
"assets/icons/lbry/lbry-logo.svg",
"assets/icons/lbry/odysee-logo.svg",
"assets/icons/lbry/madiator-logo.svg"
],
"browser_action": {
"default_title": "Watch on LBRY",
"default_popup": "pages/popup/index.html"
},
"content_scripts": [
{
"matches": [
"https://www.youtube.com/*",
"https://yewtu.be/*",
"https://vid.puffyan.us/*",
"https://invidio.xamh.de/*",
"https://invidious.kavin.rocks/*"
],
"js": [
"scripts/ytContent.js"
]
}
],
"background": {
"scripts": [
"service-worker-entry-point.js"
],
"persistent": true
}
}

59
manifest.v3.json Normal file
View file

@ -0,0 +1,59 @@
{
"manifest_version": 3,
"name": "Watch on LBRY",
"version": "2.0.1",
"icons": {
"16": "assets/icons/wol/icon16.png",
"48": "assets/icons/wol/icon48.png",
"128": "assets/icons/wol/icon128.png"
},
"permissions": [
"storage",
"tabs"
],
"host_permissions": [
"https://www.youtube.com/",
"https://yewtu.be/",
"https://vid.puffyan.us/",
"https://invidio.xamh.de/",
"https://invidious.kavin.rocks/",
"https://api.odysee.com/",
"https://lbry.tv/",
"https://odysee.com/",
"https://madiator.com/",
"https://finder.madiator.com/"
],
"web_accessible_resources": [{
"resources": [
"pages/popup/index.html",
"pages/YTtoLBRY/index.html",
"pages/import/index.html",
"assets/icons/lbry/lbry-logo.svg",
"assets/icons/lbry/odysee-logo.svg",
"assets/icons/lbry/madiator-logo.svg"
],
"matches": ["<all_urls>"],
"extension_ids": []
}],
"action": {
"default_title": "Watch on LBRY",
"default_popup": "pages/popup/index.html"
},
"content_scripts": [
{
"matches": [
"https://www.youtube.com/*",
"https://yewtu.be/*",
"https://vid.puffyan.us/*",
"https://invidio.xamh.de/*",
"https://invidious.kavin.rocks/*"
],
"js": [
"scripts/ytContent.js"
]
}
],
"background": {
"service_worker": "service-worker-entry-point.js"
}
}

17498
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,11 +5,19 @@
"scripts": {
"build:assets": "cpx \"src/**/*.{json,png,svg}\" dist/",
"watch:assets": "cpx --watch -v \"src/**/*.{json,png,svg}\" dist/",
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/scripts/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
"build:parcel": "cross-env NODE_ENV=production parcel build --no-source-maps --no-minify \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"",
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\" \"src/**/*.md\"",
"build:webext": "web-ext build --source-dir ./dist --overwrite-dest",
"build": "npm-run-all -l -p build:parcel build:assets",
"watch": "npm-run-all -l -p watch:parcel watch:assets",
"clear:dist": "rm -r ./dist ; mkdir ./dist",
"build:base": "npm-run-all -l -p build:parcel build:assets",
"pick:manifest:v2": "cp -b ./manifest.v2.json ./dist/manifest.json",
"pick:manifest:v3": "cp -b ./manifest.v3.json ./dist/manifest.json",
"build:v2": "npm run clear:dist ; npm run build:base && npm run pick:manifest:v2",
"build:v3": "npm run clear:dist ; npm run build:base && npm run pick:manifest:v3",
"build": "rm -r ./build ; mkdir ./build && npm run build:v2 && zip -r ./build/manifest-v2.zip ./dist && npm run build:v3 && zip -r ./build/manifest-v3.zip ./dist",
"watch:v2": "npm run clear:dist ; npm run pick:manifest:v2 && npm-run-all -l -p watch:parcel watch:assets",
"watch:v3": "npm run clear:dist ; npm run pick:manifest:v3 && npm-run-all -l -p watch:parcel watch:assets",
"watch": "npm run watch:v3",
"start:chrome": "web-ext run -t chromium --source-dir ./dist",
"start:firefox": "web-ext run --source-dir ./dist",
"test": "jest"

View file

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 544 B

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,158 @@
:root {
--color-master: #499375;
--color-slave: #43889d;
--color-error: rgb(245, 81, 69);
--color-gradient-0: linear-gradient(130deg, var(--color-master), var(--color-slave));
--color-gradient-1: linear-gradient(130deg, #ff7a18, #af002d 41.07%, #319197 76.05%);
--color-dark: #0e1117;
--color-light: rgb(235, 237, 241);
--gradient-animation: gradient-animation 5s linear infinite alternate;
}
body {
font-size: .75rem;
font-family: Arial, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue, sans-serif;
letter-spacing: .2ch;
}
body#page {
--root-smallest-font-size: .95rem;
--root-font-size-relative-to-screen: 1;
font-size: max(var(--root-smallest-font-size), calc(min(1vw, 2vh) * var(--root-font-size-relative-to-screen)));
}
body {
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
background-attachment: fixed;
color: var(--color-light);
padding: 0;
margin: 0;
}
body::before {
content: "";
position: fixed;
inset: 0;
background: rgba(19, 19, 19, 0.75);
}
*,
*::before,
*::after {
box-sizing: border-box;
position: relative;
}
a {
cursor: pointer;
}
p,
ul,
ol,
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
pre {
margin: 0;
white-space: break-spaces;
}
.options {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
justify-content: center;
gap: .25em;
}
.button {
display: inline-flex;
place-items: center;
place-content: center;
text-align: center;
padding: .5em 1em;
background: var(--color-dark);
color: var(--color-light);
border-radius: .5em;
border: unset;
font: inherit;
cursor: pointer;
}
.button.active {
background: var(--color-gradient-0);
}
.button.active::before {
content: "";
position: absolute;
inset: 0;
background: var(--color-gradient-0);
filter: blur(.5em);
z-index: -1;
border-radius: .5em;
}
.button.disabled {
filter: saturate(0);
pointer-events: none;
}
.filled {
background: var(--color-gradient-0);
background-clip: text;
-webkit-background-clip: text;
font-weight: bold;
color: transparent;
}
.error {
display: grid;
grid-auto-flow: column;
gap: .5em;
align-items: center;
justify-content: start;
color: var(--color-error);
}
.error::before {
content: "!";
width: 2em;
aspect-ratio: 1/1;
display: grid;
place-items: center;
letter-spacing: 0;
line-height: 0;
border: .1em solid currentColor;
border-radius: 100000vw;
font-weight: bold;
}
.overlay {
display: grid;
place-items: center;
position: fixed;
inset: 0;
font-size: 2em;
font-weight: bold;
}
.overlay::before {
content: '';
position: absolute;
inset: 0;
background-color: #0e1117;
opacity: .75;
}

View file

@ -1,16 +0,0 @@
@import '../style'
.ButtonRadio
display: flex
justify-content: center
.radio-button
@extend .button
margin: 6px
.radio-button.checked
@extend .button.active
input[type="radio"]
opacity: 0
position: absolute

View file

@ -1,31 +0,0 @@
import { h } from 'preact';
import classnames from 'classnames';
import './ButtonRadio.sass';
export interface SelectionOption {
value: string
display: string
}
export interface ButtonRadioProps<T extends string | SelectionOption = string> {
name?: string;
onChange(redirect: string): void;
value: T extends SelectionOption ? T['value'] : T;
options: T[];
}
const getAttr = (x: string | SelectionOption, key: keyof SelectionOption): string => typeof x === 'string' ? x : x[key];
export default function ButtonRadio<T extends string | SelectionOption = string>({ name = 'buttonRadio', onChange, options, value }: ButtonRadioProps<T>) {
/** If it's a string, return the string, if it's a SelectionOption get the selection option property */
return <div className='ButtonRadio'>
{options.map(o => ({ o: getAttr(o, 'value'), display: getAttr(o, 'display') })).map(({ o, display }) =>
<div key={o} className={classnames('radio-button', { 'checked': value === o })}
onClick={() => o !== value && onChange(o)}>
<input name={name} value={o} type='radio' checked={value === o} />
<label>{display}</label>
</div>
)}
</div>;
}

View file

@ -1,29 +0,0 @@
import { appRedirectUrl, parseProtocolUrl } from './lbry-url';
describe('web url parsing', () => {
const testCases: [string, string | undefined][] = [
['https://lbry.tv/@test:7/foo-123:7', 'lbry://@test:7/foo-123:7'],
['https://lbry.tv/@test1:c/foo:8', 'lbry://@test1:c/foo:8'],
['https://lbry.tv/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', 'lbry://@test1:0/foo-bar-2-baz-7:e'],
['https://lbry.tv/@test:7', 'lbry://@test:7'],
['https://lbry.tv/@test:c', 'lbry://@test:c'],
['https://lbry.tv/$/discover?t=foo%20bar', undefined],
['https://lbry.tv/$/signup?redirect=/@test1:0/foo-bar-2-baz-7:e#adasasddasdas123', undefined],
];
test.each(testCases)('redirect %s', (url, expected) => {
expect(appRedirectUrl(url)).toEqual(expected);
});
});
describe('app url parsing', () => {
const testCases: Array<[string, string[]]> = [
['test', ['test']],
['@test', ['@test']],
['lbry://@test$1/stuff', ['@test$1', 'stuff']],
];
test.each(testCases)('redirect %s', (url, expected) => {
expect(parseProtocolUrl(url)).toEqual(expected);
});
});

View file

@ -1,142 +0,0 @@
// Port of https://github.com/lbryio/lbry-sdk/blob/master/lbry/schema/url.py
interface UrlOptions {
/**
* Whether or not to encodeURIComponent the path segments.
* Doing so is a workaround such that browsers interpret it as a valid URL in a way that the desktop app understands.
*/
encode?: boolean
}
const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source;
/** Creates a named regex group */
const named = (name: string, regex: string) => `(?<${name}>${regex})`;
/** Creates a non-capturing group */
const group = (regex: string) => `(?:${regex})`;
/** Allows for one of the patterns */
const oneOf = (...choices: string[]) => group(choices.join('|'));
/** Create an lbry url claim */
const claim = (name: string, prefix = '') => group(
named(`${name}_name`, prefix + invalidNamesRegex)
+ oneOf(
group(':' + named(`${name}_claim_id`, '[0-9a-f]{1,40}')),
group('\\*' + named(`${name}_sequence`, '[1-9][0-9]*')),
group('\\$' + named(`${name}_amount_order`, '[1-9][0-9]*'))
) + '?'
);
/** Create an lbry url claim, but use the old pattern for claims */
const legacyClaim = (name: string, prefix = '') => group(
named(`${name}_name`, prefix + invalidNamesRegex)
+ oneOf(
group('#' + named(`${name}_claim_id`, '[0-9a-f]{1,40}')),
group(':' + named(`${name}_sequence`, '[1-9][0-9]*')),
group('\\$' + named(`${name}_amount_order`, '[1-9][0-9]*'))
) + '?');
export const builder = { named, group, oneOf, claim, legacyClaim, invalidNamesRegex };
/** Creates a pattern to parse lbry protocol URLs. Unused, but I left it here. */
function createProtocolUrlRegex(legacy = false) {
const claim = legacy ? builder.legacyClaim : builder.claim;
return new RegExp('^' + named('scheme', 'lbry://') + '?' + oneOf(
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
claim('channel', '@'),
claim('stream'),
) + '$');
}
/** Creates a pattern to match lbry.tv style sites by their pathname */
function createWebUrlRegex(legacy = false) {
const claim = legacy ? builder.legacyClaim : builder.claim;
return new RegExp('^/' + oneOf(
group(claim('channel_with_stream', '@') + '/' + claim('stream_in_channel')),
claim('channel', '@'),
claim('stream'),
) + '$');
}
/** Pattern for lbry.tv style sites */
export const URL_REGEX = createWebUrlRegex();
export const PROTOCOL_URL_REGEX = createProtocolUrlRegex();
const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true);
/**
* Encapsulates a lbry url path segment.
* Handles `StreamClaimNameAndModifier' and `ChannelClaimNameAndModifier`
*/
export class PathSegment {
constructor(public name: string,
public claimID?: string,
public sequence?: number,
public amountOrder?: number) { }
static fromMatchGroup(segment: string, groups: Record<string, string>) {
return new PathSegment(
groups[`${segment}_name`],
groups[`${segment}_claim_id`],
parseInt(groups[`${segment}_sequence`]),
parseInt(groups[`${segment}_amount_order`])
);
}
/** Prints the segment */
toString() {
if (this.claimID) return `${this.name}:${this.claimID}`;
if (this.sequence) return `${this.name}*${this.sequence}`;
if (this.amountOrder) return `${this.name}$${this.amountOrder}`;
return this.name;
}
}
/**
* Utility function
*
* @param ptn pattern to use; specific to the patterns defined in this file
* @param url the url to try to parse
* @returns an array of path segments; if invalid, will return an empty array
*/
function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { encode: false }): string[] {
const match = url.match(ptn)?.groups;
if (!match) return [];
const segments = match['channel_name'] ? ['channel']
: match['channel_with_stream_name'] ? ['channel_with_stream', 'stream_in_channel']
: match['stream_name'] ? ['stream']
: null;
if (!segments) throw new Error(`${url} matched the overall pattern, but could not determine type`);
return segments.map(s => PathSegment.fromMatchGroup(s, match).toString())
.map(s => options.encode ? encodeURIComponent(s) : s);
}
/**
* Produces the lbry protocl URL from the frontend URL
*
* @param url lbry frontend URL
* @param options options for the redirect
*/
export function appRedirectUrl(url: string, options?: UrlOptions): string | undefined {
const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options);
if (segments.length === 0) return;
const path = segments.join('/');
return `lbry://${path}`;
}
/**
* Parses a lbry protocol and returns its constituent path segments. Attempts the spec compliant and then the old URL schemes.
*
* @param url the lbry url
* @returns an array of path segments; if invalid, will return an empty array
*/
export function parseProtocolUrl(url: string, options: UrlOptions = { encode: false }): string[] {
for (const ptn of [PROTOCOL_URL_REGEX, PROTOCOL_URL_REGEX_LEGACY]) {
const segments = patternSegmenter(ptn, url, options);
if (segments.length === 0) continue;
return segments;
}
return [];
}

View file

@ -1,29 +0,0 @@
export type PlatformName = 'madiator.com' | 'odysee' | 'app'
export interface PlatformSettings
{
domainPrefix: string
display: string
theme: string
}
export const platformSettings: Record<PlatformName, PlatformSettings> = {
'madiator.com': { domainPrefix: 'https://madiator.com/', display: 'Madiator.com', theme: '#075656' },
odysee: { domainPrefix: 'https://odysee.com/', display: 'Odysee', theme: '#1e013b' },
app: { domainPrefix: 'lbry://', display: 'App', theme: '#075656' },
};
export const getPlatfromSettingsEntiries = () => {
return Object.entries(platformSettings) as any as [Extract<keyof typeof platformSettings, string>, PlatformSettings][]
}
export interface LbrySettings {
enabled: boolean
platform: PlatformName
}
export const DEFAULT_SETTINGS: LbrySettings = { enabled: true, platform: 'odysee' };
export function getSettingsAsync<K extends Array<keyof LbrySettings>>(...keys: K): Promise<Pick<LbrySettings, K[number]>> {
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
}

View file

@ -1,34 +0,0 @@
$background-color: #191a1c !default
$text-color: whitesmoke !default
$btn-color: #075656 !default
$btn-select: teal !default
body
width: 400px
text-align: center
background-color: $background-color
color: $text-color
font-family: sans-serif
.container
display: block
text-align: center
margin: 0 32px
margin-bottom: 15px
.button
border-radius: 5px
background-color: $btn-color
border: 4px solid $btn-color
color: $text-color
font-size: 0.8rem
font-weight: 400
padding: 4px 15px
text-align: center
&.active
border: 4px solid $btn-select
&:focus
outline: none

View file

@ -1,30 +0,0 @@
import { useReducer, useEffect } from 'preact/hooks';
import { DEFAULT_SETTINGS } from './settings';
/**
* A hook to read the settings from local storage
*
* @param initial the default value. Must have all relevant keys present and should not change
*/
export function useSettings<T extends object>(initial: T) {
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
// register change listeners, gets current values, and cleans up the listeners on unload
useEffect(() => {
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== 'local') return;
const changeSet = Object.keys(changes)
.filter(k => Object.keys(initial).includes(k))
.map(k => [k, changes[k].newValue]);
if (changeSet.length === 0) return; // no changes; no use dispatching
dispatch(Object.fromEntries(changeSet));
};
chrome.storage.onChanged.addListener(changeListener);
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>));
return () => chrome.storage.onChanged.removeListener(changeListener);
}, []);
return state;
}
/** A hook to read watch on lbry settings from local storage */
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS);

View file

@ -1,139 +0,0 @@
import chunk from 'lodash/chunk';
import groupBy from 'lodash/groupBy';
import pickBy from 'lodash/pickBy';
const LBRY_API_HOST = 'https://api.odysee.com';
const QUERY_CHUNK_SIZE = 300;
interface YtResolverResponse {
success: boolean;
error: object | null;
data: {
videos?: Record<string, string>;
channels?: Record<string, string>;
};
}
interface YtSubscription {
id: string;
etag: string;
title: string;
snippet: {
description: string;
resourceId: {
channelId: string;
};
};
}
/**
* @param file to load
* @returns a promise with the file as a string
*/
export function getFileContent(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', event => resolve(event.target?.result as string || ''));
reader.addEventListener('error', () => {
reader.abort();
reject(new DOMException(`Could not read ${file.name}`));
});
reader.readAsText(file);
});
}
export interface YTDescriptor {
id: string
type: 'channel' | 'video'
}
export const ytService = {
/**
* Reads the array of YT channels from an OPML file
*
* @param opmlContents an opml file as as tring
* @returns the channel IDs
*/
readOpml(opmlContents: string): string[] {
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml');
opmlContents = ''
return Array.from(opml.querySelectorAll('outline > outline'))
.map(outline => outline.getAttribute('xmlUrl'))
.filter((url): url is string => !!url)
.map(url => ytService.getChannelId(url))
.filter((url): url is string => !!url); // we don't want it if it's empty
},
/**
* Reads an array of YT channel IDs from the YT subscriptions JSON file
*
* @param jsonContents a JSON file as a string
* @returns the channel IDs
*/
readJson(jsonContents: string): string[] {
const subscriptions: YtSubscription[] = JSON.parse(jsonContents);
jsonContents = ''
return subscriptions.map(sub => sub.snippet.resourceId.channelId);
},
/**
* Reads an array of YT channel IDs from the YT subscriptions CSV file
*
* @param csvContent a CSV file as a string
* @returns the channel IDs
*/
readCsv(csvContent: string): string[] {
const rows = csvContent.split('\n')
csvContent = ''
return rows.map((row) => row.substr(0, row.indexOf(',')))
},
/**
* Extracts the channelID from a YT URL.
*
* Handles these two types of YT URLs:
* * /feeds/videos.xml?channel_id=*
* * /channel/*
*/
getChannelId(channelURL: string) {
const match = channelURL.match(/channel\/([^\s?]*)/);
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id');
},
/** Extracts the video ID from a YT URL */
getVideoId(url: string) {
const regex = /watch\/?\?.*v=([^\s&]*)/;
const match = url.match(regex);
return match ? match[1] : null; // match[1] is the videoId
},
getId(url: string): YTDescriptor | null {
const videoId = ytService.getVideoId(url);
if (videoId) return { id: videoId, type: 'video' };
const channelId = ytService.getChannelId(url);
if (channelId) return { id: channelId, type: 'channel' };
return null;
},
/**
* @param descriptors YT resource IDs to check
* @returns a promise with the list of channels that were found on lbry
*/
async resolveById(...descriptors: YTDescriptor[]): Promise<string[]> {
const descChunks = chunk(descriptors, QUERY_CHUNK_SIZE);
const responses: (YtResolverResponse | null)[] = await Promise.all(descChunks.map(descChunk => {
const groups = groupBy(descChunk, d => d.type);
const params = new URLSearchParams(pickBy({
video_ids: groups['video']?.map(s => s.id).join(','),
channel_ids: groups['channel']?.map(s => s.id).join(','),
}));
return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`, { cache: 'force-cache' })
.then(rsp => rsp.ok ? rsp.json() : null);
}));
return responses.filter((rsp): rsp is YtResolverResponse => !!rsp)
.flatMap(rsp => [...Object.values(rsp.data.videos || {}), ...Object.values(rsp.data.channels || {})]) // flatten the results into a 1D array
.filter(s => s);
},
};

142
src/components/dialogs.tsx Normal file
View file

@ -0,0 +1,142 @@
import { h } from 'preact'
import { useEffect, useRef, useState } from 'preact/hooks'
type Message = string
type Alert = { type: 'alert' | 'prompt' | 'confirm', message: Message, resolve: (data: string | boolean | null) => void }
export type DialogManager = ReturnType<typeof createDialogManager>
export function createDialogManager() {
const [alerts, setAlerts] = useState({} as Record<string, Alert>)
const id = crypto.randomUUID()
function add(alert: Alert) {
setAlerts({ ...alerts, alert })
}
function remove() {
delete alerts[id]
setAlerts({ ...alerts })
}
return {
useAlerts() { return alerts },
async alert(message: Message) {
return await new Promise<void>((resolve) => add({
message, type: 'alert', resolve: () => {
resolve()
remove()
}
}))
},
async prompt(message: Message) {
return await new Promise<string | null>((resolve) => add({
message, type: 'prompt', resolve: (data) => {
resolve(data?.toString() ?? null)
remove()
}
}))
},
async confirm(message: Message) {
return await new Promise<boolean>((resolve) => add({
message, type: 'confirm', resolve: (data) => {
resolve(!!data)
remove()
}
}))
}
}
}
export function Dialogs(params: { manager: ReturnType<typeof createDialogManager> }) {
const alerts = params.manager.useAlerts()
let currentAlert = Object.values(alerts)[0]
if (!currentAlert) return <noscript></noscript>
const [value, setValue] = useState(null as Parameters<typeof currentAlert['resolve']>[0])
let cancelled = false
const dialog = useRef(null as any as HTMLDialogElement)
useEffect(() => {
if (!dialog.current) return
if (!dialog.current.open) dialog.current.showModal()
const onClose = () => currentAlert.resolve(null)
dialog.current.addEventListener('close', onClose)
return dialog.current.removeEventListener('close', onClose)
})
return <dialog class="alert-dialog" ref={dialog}>
<style>
{`
.alert-dialog
{
position: fixed;
border: none;
background: var(--color-dark);
color: var(--color-light);
margin-bottom: 0;
width: 100%;
max-width: unset;
padding: 1.5em;
}
.alert-dialog::before {
content: "";
display: block;
background: var(--color-gradient-0);
height: 0.1em;
width: 100%;
position: absolute;
left: 0;
top: 0;
}
.alert-dialog form {
display: grid;
gap: 2em
}
.alert-dialog form .fields {
display: grid;
gap: .5em
}
.alert-dialog form .fields pre {
font: inherit;
}
.alert-dialog form .actions {
display: flex;
gap: .5em;
font-size: 1.1em;
font-weight: bold;
}
.alert-dialog form .actions::before {
content: "";
flex-grow: 1111111111111;
}`}
</style>
<form method='dialog' onSubmit={(event) => {
event.preventDefault()
currentAlert.resolve(cancelled ? null : currentAlert.type === 'confirm' ? true : value)
}}>
<div class="fields">
<pre>{currentAlert.message}</pre>
{currentAlert.type === 'prompt' && <input type='text' onInput={(event) => setValue(event.currentTarget.value)} />}
</div>
<div class="actions">
{/* This is here to capture, return key */}
<button style="position:0;opacity:0;pointer-events:none"></button>
{currentAlert.type !== 'alert' && <button className='button' onClick={() => cancelled = true}>Cancel</button>}
<button className='button active'>
{
currentAlert.type === 'alert' ? 'Ok'
: currentAlert.type === 'confirm' ? 'Confirm'
: currentAlert.type === 'prompt' ? 'Apply'
: 'Ok'
}
</button>
</div>
</form>
</dialog>
}

4
src/global.d.ts vendored
View file

@ -1,4 +0,0 @@
declare module '*.md' {
var _: string;
export default _;
}

View file

@ -1,49 +0,0 @@
{
"name": "Watch on LBRY",
"version": "1.7.5",
"permissions": [
"https://www.youtube.com/",
"https://invidio.us/channel/*",
"https://invidio.us/watch?v=*",
"https://api.odysee.com/*",
"https://lbry.tv/*",
"https://odysee.com/*",
"https://madiator.com/*",
"tabs",
"storage"
],
"content_scripts": [
{
"matches": [
"https://www.youtube.com/*"
],
"js": [
"scripts/ytContent.js"
]
}
],
"background": {
"scripts": [
"scripts/storageSetup.js",
"scripts/tabOnUpdated.js"
],
"persistent": false
},
"browser_action": {
"default_title": "Watch on LBRY",
"default_popup": "popup/popup.html"
},
"web_accessible_resources": [
"popup.html",
"tools/YTtoLBRY.html",
"icons/lbry/lbry-logo.svg",
"icons/lbry/odysee-logo.svg",
"icons/lbry/madiator-logo.svg"
],
"icons": {
"16": "icons/wol/icon16.png",
"48": "icons/wol/icon48.png",
"128": "icons/wol/icon128.png"
},
"manifest_version": 2
}

200
src/modules/crypto/index.ts Normal file
View file

@ -0,0 +1,200 @@
import path from 'path'
import type { DialogManager } from '../../components/dialogs'
import { getExtensionSettingsAsync, setExtensionSetting, ytUrlResolversSettings } from "../../settings"
import { getFileContent } from '../file'
async function generateKeys() {
const keys = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
// Consider using a 4096-bit key for systems that require long-term security
modulusLength: 384,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-1",
},
true,
["sign", "verify"]
)
return {
publicKey: await exportPublicKey(keys.publicKey),
privateKey: await exportPrivateKey(keys.privateKey)
}
}
async function exportPrivateKey(key: CryptoKey) {
const exported = await crypto.subtle.exportKey(
"pkcs8",
key
)
return Buffer.from(exported).toString('base64')
}
const publicKeyPrefix = `MEwwDQYJKoZIhvcNAQEBBQADOwAwOAIxA`
const publicKeySuffix = `IDAQAB` //`wIDAQAB` `WIDAQAB`
const publicKeyLength = 65
async function exportPublicKey(key: CryptoKey) {
const exported = await crypto.subtle.exportKey(
"spki",
key
)
const publicKey = Buffer.from(exported).toString('base64')
return publicKey.substring(publicKeyPrefix.length, publicKeyPrefix.length + publicKeyLength)
}
function importPrivateKey(base64: string) {
return crypto.subtle.importKey(
"pkcs8",
Buffer.from(base64, 'base64'),
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-1",
},
true,
["sign"]
)
}
export async function sign(data: string, privateKey: string) {
return Buffer.from(await crypto.subtle.sign(
{ name: "RSASSA-PKCS1-v1_5" },
await importPrivateKey(privateKey),
await crypto.subtle.digest({ name: 'SHA-1' }, Buffer.from(data))
)).toString('base64')
}
export function resetProfileSettings() {
setExtensionSetting('publicKey', null)
setExtensionSetting('privateKey', null)
}
async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: string, data: T) {
const settings = await getExtensionSettingsAsync()
const url = new URL(ytUrlResolversSettings.madiatorFinder.href)
url.pathname = path.join(url.pathname, pathname)
url.searchParams.set('data', JSON.stringify(data))
if (!settings.privateKey || !settings.publicKey)
throw new Error('There is no profile.')
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), settings.privateKey!),
publicKey: settings.publicKey
}))
const respond = await fetch(url.href, { method })
if (respond.ok) return respond.json()
throw new Error((await respond.json()).message)
}
export async function generateProfileAndSetNickname(dialogManager: DialogManager, overwrite = false) {
let { publicKey, privateKey } = await getExtensionSettingsAsync()
let nickname
while (true) {
nickname = await dialogManager.prompt("Pick a nickname")
if (nickname) break
if (nickname === null) return
await dialogManager.alert("Invalid nickname")
}
try {
if (overwrite || !privateKey || !publicKey) {
resetProfileSettings()
await generateKeys().then((keys) => {
publicKey = keys.publicKey
privateKey = keys.privateKey
})
setExtensionSetting('publicKey', publicKey)
setExtensionSetting('privateKey', privateKey)
}
await apiRequest('POST', '/profile', { nickname })
await dialogManager.alert(`Your nickname has been set to ${nickname}`)
} catch (error: any) {
resetProfileSettings()
await dialogManager.alert(error.message)
}
}
export async function purgeProfile(dialogManager: DialogManager) {
try {
if (!await dialogManager.confirm("This will purge all of your online and offline profile data.\nStill wanna continue?")) return
await apiRequest('POST', '/profile/purge', {})
resetProfileSettings()
await dialogManager.alert(`Your profile has been purged`)
} catch (error: any) {
await dialogManager.alert(error.message)
}
}
export async function getProfile() {
let { publicKey, privateKey } = await getExtensionSettingsAsync()
return (await apiRequest('GET', '/profile', { publicKey })) as { nickname: string, score: number, publickKey: string }
}
export function friendlyPublicKey(publicKey: string | null) {
// This is copy paste of Madiator Finder's friendly public key
return `${publicKey?.substring(0, 32)}...`
}
function download(data: string, filename: string, type: string) {
const file = new Blob([data], { type: type })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
})
}
// Using callback here because there is no good solution for detecting cancel event
export function inputKeyFile(callback: (file: File | null) => void) {
const input = document.createElement("input")
input.type = 'file'
input.accept = '.wol-keys.json'
input.click()
input.addEventListener("change", () => callback(input.files?.[0] ?? null))
}
interface ExportedProfileKeysFile {
publicKey: string
privateKey: string
}
export async function exportProfileKeysAsFile() {
const { publicKey, privateKey } = await getExtensionSettingsAsync()
const json = JSON.stringify({
publicKey,
privateKey
})
download(json, `watch-on-lbry-profile-export-${friendlyPublicKey(publicKey)}.wol-keys.json`, 'application/json')
}
export async function importProfileKeysFromFile(dialogManager: DialogManager, file: File) {
try {
let settings = await getExtensionSettingsAsync()
if (settings.publicKey && !await dialogManager.confirm(
"This will overwrite your old keypair." +
"\nStill wanna continue?\n\n" +
"NOTE: Without keypair you can't purge your data online.\n" +
"So if you wish to purge, please use purging instead."
)) return false
const json = await getFileContent(file)
if (!json) return false
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
setExtensionSetting('publicKey', publicKey)
setExtensionSetting('privateKey', privateKey)
return true
} catch (error: any) {
await dialogManager.alert(error.message)
return false
}
}

15
src/modules/file/index.ts Normal file
View file

@ -0,0 +1,15 @@
/**
* @param file to load
* @returns a promise with the file as a string
*/
export function getFileContent(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', event => resolve(event.target?.result as string || ''))
reader.addEventListener('error', () => {
reader.abort()
reject(new DOMException(`Could not read ${file.name}`))
})
reader.readAsText(file)
})
}

83
src/modules/yt/index.ts Normal file
View file

@ -0,0 +1,83 @@
interface YtExportedJsonSubscription {
id: string
etag: string
title: string
snippet: {
description: string
resourceId: {
channelId: string
}
}
}
/**
* Reads the array of YT channels from an OPML file
*
* @param opmlContents an opml file as as tring
* @returns the channel IDs
*/
export function getSubsFromOpml(opmlContents: string): string[] {
const opml = new DOMParser().parseFromString(opmlContents, 'application/xml')
opmlContents = ''
return Array.from(opml.querySelectorAll('outline > outline'))
.map(outline => outline.getAttribute('xmlUrl'))
.filter((url): url is string => !!url)
.map(url => getChannelId(url))
.filter((url): url is string => !!url) // we don't want it if it's empty
}
/**
* Reads an array of YT channel IDs from the YT subscriptions JSON file
*
* @param jsonContents a JSON file as a string
* @returns the channel IDs
*/
export function getSubsFromJson(jsonContents: string): string[] {
const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents)
jsonContents = ''
return subscriptions.map(sub => sub.snippet.resourceId.channelId)
}
/**
* Reads an array of YT channel IDs from the YT subscriptions CSV file
*
* @param csvContent a CSV file as a string
* @returns the channel IDs
*/
export function getSubsFromCsv(csvContent: string): string[] {
const rows = csvContent.split('\n')
csvContent = ''
return rows.slice(1).map((row) => row.substring(0, row.indexOf(',')))
}
/**
* Extracts the channelID from a YT URL.
*
* Handles these two types of YT URLs:
* * /feeds/videos.xml?channel_id=*
* * /channel/*
*/
export function getChannelId(channelURL: string) {
const match = channelURL.match(/channel\/([^\s?]*)/)
return match ? match[1] : new URL(channelURL).searchParams.get('channel_id')
}
export function parseYouTubeURLTimeString(timeString: string) {
const signs = timeString.replace(/[0-9]/g, '')
if (signs.length === 0) return null
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
let total = 0
for (let i = 0; i < signs.length; i++) {
let t = parseInt(numbers[i])
switch (signs[i]) {
case 'd': t *= 24
case 'h': t *= 60
case 'm': t *= 60
case 's': break
default: return null
}
total += t
}
return total
}

View file

@ -0,0 +1,82 @@
// This should only work in background
if (typeof chrome.extension === 'undefined') throw new Error("YT urlCache can only be accessed from extension windows and service-workers.")
let db = new Promise<IDBDatabase>((resolve, reject) => {
if (typeof self.indexedDB !== 'undefined') {
const openRequest = indexedDB.open("yt-url-resolver-cache")
openRequest.addEventListener('upgradeneeded', () => openRequest.result.createObjectStore("store").createIndex("expireAt", "expireAt"))
openRequest.addEventListener('success', () => {
resolve(openRequest.result)
clearExpired()
}, { once: true })
}
else reject(`IndexedDB not supported`)
})
async function clearExpired() {
return new Promise<void>(async (resolve, reject) => {
const transaction = (await db).transaction("store", "readwrite")
const range = IDBKeyRange.upperBound(new Date())
const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range)
expireAtCursorRequest.addEventListener('error', () => reject(expireAtCursorRequest.error))
expireAtCursorRequest.addEventListener('success', () => {
try {
const expireCursor = expireAtCursorRequest.result
if (!expireCursor) return
expireCursor.delete()
expireCursor.continue()
resolve()
}
catch (ex) {
reject(ex)
}
})
})
}
async function clearAll() {
return await new Promise<void>(async (resolve, reject) => {
const store = (await db).transaction("store", "readwrite").objectStore("store")
if (!store) return resolve()
const request = store.clear()
request.addEventListener('success', () => resolve())
request.addEventListener('error', () => reject(request.error))
})
}
async function put(url: string | null, id: string): Promise<void> {
return await new Promise(async (resolve, reject) => {
const store = (await db).transaction("store", "readwrite").objectStore("store")
if (!store) return resolve()
const expireAt = !url ? new Date(Date.now() + 1 * 60 * 60 * 1000) : new Date(Date.now() + 15 * 24 * 60 * 60 * 1000)
const request = store.put({ value: url, expireAt }, id)
console.log('caching', id, url, 'until:', expireAt)
request.addEventListener('success', () => resolve())
request.addEventListener('error', () => reject(request.error))
})
}
// string means there is cache of lbrypathname
// null means there is cache of that id has no lbrypathname
// undefined means there is no cache
async function get(id: string): Promise<string | null | undefined> {
const response = (await new Promise(async (resolve, reject) => {
const store = (await db).transaction("store", "readonly").objectStore("store")
if (!store) return reject(`Can't find object store.`)
const request = store.get(id)
request.addEventListener('success', () => resolve(request.result))
request.addEventListener('error', () => reject(request.error))
}) as { value: string | null, expireAt: Date } | undefined)
if (response === undefined) return undefined
if (response.expireAt <= new Date()) {
await clearExpired()
return undefined
}
return response.value
}
export const lbryUrlCache = { put, get, clearAll }

View file

@ -0,0 +1,88 @@
import { chunk } from "lodash"
import path from "path"
import { getExtensionSettingsAsync, SourcePlatform, ytUrlResolversSettings } from "../../settings"
import { sign } from "../crypto"
import { lbryUrlCache } from "./urlCache"
const QUERY_CHUNK_SIZE = 100
export type ResolveUrlTypes = 'video' | 'channel'
export type YtUrlResolveItem = { type: ResolveUrlTypes, id: string }
type Results = Record<string, YtUrlResolveItem>
type Paramaters = YtUrlResolveItem[]
interface ApiResponse {
data: {
channels?: Record<string, string>
videos?: Record<string, string>
}
}
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
const { urlResolver: urlResolverSettingName, privateKey, publicKey } = await getExtensionSettingsAsync()
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
async function requestChunk(params: Paramaters) {
const results: Results = {}
// Check for cache first, add them to the results if there are any cache
// And remove them from the params, so we dont request for them
params = (await Promise.all(params.map(async (item) => {
const cachedLbryUrl = await lbryUrlCache.get(item.id)
// Cache can be null, if there is no lbry url yet
if (cachedLbryUrl !== undefined) {
// Null values shouldn't be in the results
if (cachedLbryUrl !== null) results[item.id] = { id: cachedLbryUrl, type: item.type }
return null
}
// No cache found
return item
}))).filter((o) => o) as Paramaters
if (params.length === 0) return results
const url = new URL(`${urlResolverSetting.href}`)
url.pathname = path.join(url.pathname, '/resolve')
url.searchParams.set('video_ids', params.filter((item) => item.type === 'video').map((item) => item.id).join(','))
url.searchParams.set('channel_ids', params.filter((item) => item.type === 'channel').map((item) => item.id).join(','))
if (urlResolverSetting.signRequest && publicKey && privateKey)
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), privateKey),
publicKey
}))
const controller = new AbortController()
// 5 second timeout:
const timeoutId = setTimeout(() => controller.abort(), 15000)
const apiResponse = await fetch(url.toString(), { cache: 'no-store', signal: controller.signal })
clearTimeout(timeoutId)
if (apiResponse.ok) {
const response: ApiResponse = await apiResponse.json()
for (const item of params) {
const lbryUrl = (item.type === 'channel' ? response.data.channels : response.data.videos)?.[item.id]?.replaceAll('#', ':') ?? null
// we cache it no matter if its null or not
await lbryUrlCache.put(lbryUrl, item.id)
if (lbryUrl) results[item.id] = { id: lbryUrl, type: item.type }
}
}
return results
}
const results: Results = {}
const chunks = chunk(params, QUERY_CHUNK_SIZE)
let i = 0
if (progressCallback) progressCallback(0)
for (const chunk of chunks) {
if (progressCallback) progressCallback(++i / (chunks.length + 1))
Object.assign(results, await requestChunk(chunk))
}
if (progressCallback) progressCallback(1)
return results
}

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Subscription Converter</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="../../assets/styles/common.css" />
<script src="main.tsx" charset="utf-8" defer></script>
</head>
<body id="page">
<div id="root" />
</body>
</html>

View file

@ -0,0 +1,86 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { getFileContent } from '../../modules/file'
import { getSubsFromCsv, getSubsFromJson, getSubsFromOpml } from '../../modules/yt'
import { resolveById } from '../../modules/yt/urlResolve'
import { targetPlatformSettings, useExtensionSettings } from '../../settings'
async function getSubscribedChannelIdsFromFile(file: File) {
const ext = file.name.split('.').pop()?.toLowerCase()
const content = await getFileContent(file)
switch (ext) {
case 'xml':
case 'opml':
return getSubsFromOpml(content)
case 'csv':
return getSubsFromCsv(content)
default:
return getSubsFromJson(content)
}
}
async function findChannels(channelIds: string[], progressCallback: Parameters<typeof resolveById>['1']) {
const resultItems = await resolveById(channelIds.map((channelId) => ({ id: channelId, type: 'channel' })), progressCallback)
return Object.values(resultItems).map((item) => item.id)
}
function Conversion() {
const [file, setFile] = useState(null as File | null)
const [progress, setProgress] = useState(0)
const [lbryChannelIds, setLbryChannels] = useState([] as Awaited<ReturnType<typeof findChannels>>)
const settings = useExtensionSettings()
let loading = progress > 0 && progress !== 1
return <div className='conversion'>
<form onSubmit={async (event) => {
event.preventDefault()
if (file) setLbryChannels(await findChannels(await getSubscribedChannelIdsFromFile(file), (progress) => setProgress(progress)))
}}
>
<div class="fields">
<label for="conversion-file">Select YouTube Subscriptions</label>
<input id="conversion-file" type='file' onChange={event => setFile(event.currentTarget.files?.length ? event.currentTarget.files[0] : null)} />
</div>
<div class="actions">
<button className={`button ${!file || progress > 0 ? '' : 'active'}`} disabled={!file || loading}>
{loading ? `${(progress * 100).toFixed(1)}%` : 'Start Conversion!'}
</button>
</div>
</form>
{
progress === 1 &&
<div class="results">
<b>Results:</b>
{
lbryChannelIds.length > 0
? lbryChannelIds.map((lbryChannelId) =>
<article class="result-item">
<a href={`${targetPlatformSettings[settings.targetPlatform].domainPrefix}${lbryChannelId}`}>{lbryChannelId}</a>
</article>)
: <span class="error">No Result</span>
}
</div>
}
</div>
}
function YTtoLBRY() {
return <main>
<Conversion />
<aside class="help">
<iframe allowFullScreen
src='https://odysee.com/$/embed/convert-subscriptions-from-YouTube-to-LBRY/36f3a010295afe1c55e91b63bcb2eabc028ec86c?r=8bgP4hEdbd9jwBJmhEaqP3dD75LzsUob' />
<section><h1 id="getting-your-subscription-data">Getting your subscription data</h1>
<ol>
<li>Go to <a href="https://takeout.google.com/settings/takeout" target='_blank'>https://takeout.google.com/settings/takeout</a></li>
<li>Deselect everything except <code>YouTube and YouTube Music</code> and within that only select <code>subscriptions</code></li>
<li>Go through the process and create the export</li>
<li>Once it's exported, open the archive and find <code>YouTube and YouTube Music/subscriptions/subscriptions.(json/csv/opml)</code> and upload it to the extension</li>
</ol>
</section>
</aside>
</main>
}
render(<YTtoLBRY />, document.getElementById('root')!)

View file

@ -0,0 +1,86 @@
main {
display: flex;
flex-wrap: wrap-reverse;
min-height: 100vh;
gap: 2em;
padding: 1em;
overflow-wrap: break-word;
align-items: start;
}
.conversion,
.help {
flex: 1;
min-width: 40em;
display: grid;
gap: 1em;
}
.conversion>*,
.help>* {
background-color: rgba(0, 0, 0, 0.5);
border-radius: .5em;
padding: .5em;
}
.help iframe {
width: 100%;
max-width: 100%;
aspect-ratio: 16/9;
max-height: 50vh;
border: none;
}
.conversion {
height: 100%;
}
.conversion form {
display: grid;
gap: 1em;
justify-items: center;
}
.conversion form .fields {
display: grid;
gap: .5em;
}
.conversion form .actions {
display: flex;
flex-wrap: wrap;
gap: .5em;
}
.conversion form .actions::after {
content: "";
flex-grow: 100000000000000000;
}
.conversion form button {
cursor: pointer;
}
.conversion form button:disabled {
cursor: not-allowed;
}
.conversion .results {
display: grid;
gap: .5em;
}
.help a,
.conversion .results a {
background: var(--color-gradient-0);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-weight: bold;
}
.conversion .results .result-item {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="../../assets/styles/common.css" />
<link rel="stylesheet" href="style.css" />
<script src="main.tsx" defer></script>
</head>
<body>
<div id="root" />
</body>
</html>

62
src/pages/import/main.tsx Normal file
View file

@ -0,0 +1,62 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { createDialogManager, Dialogs } from '../../components/dialogs'
import { importProfileKeysFromFile, inputKeyFile } from '../../modules/crypto'
export async function openImportPopup() {
const importPopupWindow = open(
'/pages/import/index.html',
'Import Profile',
[
`height=${Math.max(document.body.clientHeight, screen.height * .5)}`,
`width=${document.body.clientWidth}`,
`toolbar=0,menubar=0,location=0`,
`top=${screenY}`,
`left=${screenX}`
].join(','))
close()
importPopupWindow?.focus()
}
function ImportPage() {
const [loading, updateLoading] = useState(() => false)
async function loads<T>(operation: Promise<T>) {
try {
updateLoading(true)
await operation
} catch (error) {
console.error(error)
}
finally {
updateLoading(false)
}
}
function importProfile() {
inputKeyFile(async (file) => file && await loads(
importProfileKeysFromFile(dialogManager, file)
.then((success) => success && (location.pathname = '/pages/popup/index.html'))
))
}
const dialogManager = createDialogManager()
return <div id='popup'>
<Dialogs manager={dialogManager} />
<main>
<section>
<label>Import your profile</label>
<p>Import your unique keypair.</p>
<div className='options'>
<a onClick={() => importProfile()} className={`button`}>Import</a>
</div>
</section>
</main>
{loading && <div class="overlay">
<span>Loading...</span>
</div>}
</div>
}
render(<ImportPage />, document.getElementById('root')!)

View file

@ -0,0 +1,35 @@
header {
display: grid;
gap: .5em;
padding: .75em;
position: sticky;
top: 0;
background: rgba(19, 19, 19, 0.5);
justify-items: center;
}
main {
display: grid;
gap: 2em;
padding: 1.5em 0.5em;
}
section {
display: grid;
justify-items: center;
text-align: center;
gap: .75em;
}
section label {
font-size: 1.75em;
font-weight: bold;
text-align: center;
}
#popup {
width: 35em;
max-width: 100%;
overflow: hidden;
margin: auto;
}

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="../../assets/styles/common.css" />
<link rel="stylesheet" href="style.css" />
<script src="main.tsx" defer></script>
</head>
<body>
<div id="root" />
</body>
</html>

236
src/pages/popup/main.tsx Normal file
View file

@ -0,0 +1,236 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { createDialogManager, Dialogs } from '../../components/dialogs'
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, purgeProfile, resetProfileSettings } from '../../modules/crypto'
import { lbryUrlCache } from '../../modules/yt/urlCache'
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, setExtensionSetting, useExtensionSettings } from '../../settings'
import { openImportPopup } from '../import/main'
/** Gets all the options for redirect destinations as selection options */
const targetPlatforms = getTargetPlatfromSettingsEntiries()
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
const { targetPlatform, urlResolver, redirectChannel, redirectVideo, redirectVideoPlaylist, buttonVideoSub, buttonChannelSub, buttonVideoPlayer, privateKey, publicKey } = useExtensionSettings()
let [loading, updateLoading] = useState(() => false)
let [route, updateRoute] = useState<string>(() => '')
const dialogManager = createDialogManager()
const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...'
async function loads<T>(operation: Promise<T>) {
try {
updateLoading(true)
await operation
} catch (error) {
console.error(error)
}
finally {
updateLoading(false)
}
}
return <div id='popup'>
<Dialogs manager={dialogManager} />
{
<header>
{
publicKey &&
<section>
<label>{nickname}</label>
<p>{friendlyPublicKey(publicKey)}</p>
<span><b>Score: {params.profile?.score ?? '...'}</b> - <a target='_blank' href="https://finder.madiator.com/leaderboard" class="filled">🔗Leaderboard</a></span>
{urlResolver !== 'madiatorFinder' && <span class="error">You need to use Madiator Finder API for scoring to work</span>}
</section>
}
{
route !== ''
?
<section>
<a onClick={() => updateRoute('')} className="filled"> Back</a>
</section>
:
<section>
<a className='filled' onClick={() => updateRoute('profile')}>Profile Settings</a>
</section>
}
</header>
}
{
route === 'profile' ?
publicKey ?
<main>
<section>
<div className='options'>
<a onClick={() => loads(generateProfileAndSetNickname(dialogManager)).then(() => renderPopup())} className={`button active`}>
Change Nickname
</a>
<a onClick={async () => {
if (!await dialogManager.confirm("This will delete your keypair from this device."
+ "\nStill wanna continue?"
+ "\n\nNOTE: Without keypair you can't purge your data online."
+ "\nSo if you wish to purge, please use purging instead.")) return
resetProfileSettings()
renderPopup()
}}
className={`button`}
>
Forget/Logout
</a>
</div>
</section>
<section>
<label>Backup your account</label>
<p>Import and export your unique keypair.</p>
<div className='options'>
<a onClick={() => exportProfileKeysAsFile()} className={`button active`}>
Export
</a>
<a onClick={() => openImportPopup()}
className={`button`}
>
Import
</a>
</div>
</section>
<section>
<label>Purge your profile and data!</label>
<p>Purge your profile data online and offline.</p>
<div className='options'>
<div className="center">
<span className='filled'>(°° </span>
</div>
<a onClick={() => loads(purgeProfile(dialogManager)).then(() => renderPopup())} className={`button`}>
Purge Everything!!
</a>
</div>
</section>
<section>
<label>Generate new profile</label>
<p>Generate a new keypair.</p>
<div className='options'>
<a onClick={async () => await dialogManager.confirm("This will overwrite your old keypair.\nStill wanna continue?\n\nNOTE: Without keypair you can't purge your data online.\nSo if you wish to purge, please use purging instead.")
&& loads(generateProfileAndSetNickname(dialogManager, true)).then(() => renderPopup())
}
className={`button`}
>
Generate New Account
</a>
</div>
</section>
</main>
:
<main>
<section>
<label>You don't have a profile.</label>
<p>You can either import keypair for an existing profile or generate a new profile keypair.</p>
<div className='options'>
<a onClick={() => openImportPopup()} className={`button`}>
Import
</a>
<a onClick={() => loads(generateProfileAndSetNickname(dialogManager)).then(() => renderPopup())} className={`button active`}>
Generate
</a>
</div>
</section>
</main>
:
route === 'advanced' ?
<main>
<section>
<label>Which platform you would like to redirect to?</label>
<div className='options'>
{targetPlatforms.map(([name, value]) =>
<a onClick={() => setExtensionSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
{value.displayName}
</a>
)}
</div>
</section>
<section>
<label>Which resolver API you want to use?</label>
<div className='options'>
{ytUrlResolverOptions.map(([name, value]) =>
<a onClick={() => setExtensionSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
{value.name}
</a>
)}
</div>
<a onClick={() => loads(lbryUrlCache.clearAll().then(() => dialogManager.alert("Cleared Cache!")))} className={`button active`}>
Clear Resolver Cache
</a>
</section>
</main>
:
<main>
<section>
<label>Auto redirect when:</label>
<div className='options'>
<div class="toggle-option">
<span>Playing a video</span>
<a onClick={() => setExtensionSetting('redirectVideo', !redirectVideo)} className={`button ${redirectVideo ? 'active' : ''}`}>
{redirectVideo ? 'Active' : 'Deactive'}
</a>
</div>
<div class="toggle-option">
<span>Playing a playlist</span>
<a onClick={() => setExtensionSetting('redirectVideoPlaylist', !redirectVideoPlaylist)} className={`button ${redirectVideoPlaylist ? 'active' : ''}`}>
{redirectVideoPlaylist ? 'Active' : 'Deactive'}
</a>
</div>
<div class="toggle-option">
<span>Viewing a channel</span>
<a onClick={() => setExtensionSetting('redirectChannel', !redirectChannel)} className={`button ${redirectChannel ? 'active' : ''}`}>
{redirectChannel ? 'Active' : 'Deactive'}
</a>
</div>
</div>
</section>
<section>
<label>Show redirect button on:</label>
<div className='options'>
<div className="toggle-option">
<span>Video Page</span>
<a onClick={() => setExtensionSetting('buttonVideoSub', !buttonVideoSub)} className={`button ${buttonVideoSub ? 'active' : ''}`}>
{buttonVideoSub ? 'Active' : 'Deactive'}
</a>
</div>
<div className="toggle-option">
<span>Channel Page</span>
<a onClick={() => setExtensionSetting('buttonChannelSub', !buttonChannelSub)} className={`button ${buttonChannelSub ? 'active' : ''}`}>
{buttonChannelSub ? 'Active' : 'Deactive'}
</a>
</div>
<div className="toggle-option">
<span>Video Player</span>
<a onClick={() => setExtensionSetting('buttonVideoPlayer', !buttonVideoPlayer)} className={`button ${buttonVideoPlayer ? 'active' : ''}`}>
{buttonVideoPlayer ? 'Active' : 'Deactive'}
</a>
</div>
</div>
</section>
<section>
<label>Tools</label>
<a target='_blank' href='/pages/YTtoLBRY/index.html' className={`filled`}>
Subscription Converter
</a>
<a className='filled' onClick={() => updateRoute('advanced')}>Advanced Settings</a>
</section>
</main>
}
{loading && <div class="overlay">
<span>Loading...</span>
</div>}
</div>
}
function renderPopup() {
render(<WatchOnLbryPopup profile={null} />, document.getElementById('root')!)
getProfile().then((profile) => render(<WatchOnLbryPopup profile={profile} />, document.getElementById('root')!))
}
renderPopup()

55
src/pages/popup/style.css Normal file
View file

@ -0,0 +1,55 @@
#popup {
width: 40em;
overflow: hidden;
margin: auto;
}
header {
display: grid;
gap: .5em;
padding: .75em;
position: sticky;
top: 0;
background: rgba(19, 19, 19, 0.5);
justify-items: center;
}
main {
display: grid;
gap: 2em;
padding: 1.5em 0.5em;
}
section {
display: grid;
justify-items: center;
text-align: center;
gap: .75em;
}
label {
font-size: 1.75em;
font-weight: bold;
text-align: center;
}
section>* {
padding: 0 1rem;
}
.center {
display: grid;
place-items: center;
}
.left {
display: grid;
justify-items: start;
align-items: center;
text-align: left;
}
.toggle-option {
display: grid;
gap: .5em;
}

View file

@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="popup.tsx" defer></script>
</head>
<body>
<div class="container">
<div id="root" />
</div>
</body>
</html>

View file

@ -1,4 +0,0 @@
.radio-label
font-size: 1.1rem
margin: 15px auto
display: block

View file

@ -1,33 +0,0 @@
import { h, render } from 'preact'
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
import { getPlatfromSettingsEntiries, LbrySettings, PlatformName } from '../common/settings'
import { useLbrySettings } from '../common/useSettings'
import './popup.sass'
/** Utilty to set a setting in the browser */
const setSetting = <K extends keyof LbrySettings>(setting: K, value: LbrySettings[K]) => chrome.storage.local.set({ [setting]: value });
/** Gets all the options for redirect destinations as selection options */
const platformOptions: SelectionOption[] = getPlatfromSettingsEntiries()
.map(([value, { display }]) => ({ value, display }));
function WatchOnLbryPopup() {
const { enabled, platform } = useLbrySettings();
return <div className='container'>
<label className='radio-label'>Enable Redirection:</label>
<ButtonRadio value={enabled ? 'YES' : 'NO'} options={['YES', 'NO']}
onChange={enabled => setSetting('enabled', enabled.toLowerCase() === 'yes')} />
<label className='radio-label'>Where would you like to redirect?</label>
<ButtonRadio value={platform} options={platformOptions}
onChange={(platform: PlatformName) => setSetting('platform', platform)} />
<label className='radio-label'>Other useful tools:</label>
<a href='/tools/YTtoLBRY.html' target='_blank'>
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
</a>
</div>;
}
render(<WatchOnLbryPopup />, document.getElementById('root')!);

36
src/scripts/background.ts Normal file
View file

@ -0,0 +1,36 @@
import { resolveById } from "../modules/yt/urlResolve"
const onGoingLbryPathnameRequest: Record<string, ReturnType<typeof resolveById>> = {}
chrome.runtime.onMessage.addListener(({ method, data }, sender, sendResponse) => {
function resolve(result: Awaited<ReturnType<typeof resolveById>>) {
sendResponse(JSON.stringify(result))
}
(async () => {
switch (method) {
case 'openTab':
{
const { href }: { href: string } = JSON.parse(data)
chrome.tabs.create({ url: href, active: sender.tab?.active, index: sender.tab ? sender.tab.index + 1 : undefined })
}
break
case 'resolveUrl':
try {
const params: Parameters<typeof resolveById> = JSON.parse(data)
// Don't create a new Promise for same ID until on going one is over.
const promise = onGoingLbryPathnameRequest[data] ?? (onGoingLbryPathnameRequest[data] = resolveById(...params))
resolve(await promise)
} catch (error) {
sendResponse(`error: ${(error as any).toString()}`)
console.error(error)
}
finally {
delete onGoingLbryPathnameRequest[data]
}
break
}
})()
return true
})

View file

@ -1,28 +0,0 @@
import { DEFAULT_SETTINGS, LbrySettings, getSettingsAsync } from '../common/settings';
/** Reset settings to default value and update the browser badge text */
async function initSettings() {
const settings = await getSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array<keyof LbrySettings>);
// get all the values that aren't set and use them as a change set
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof LbrySettings, LbrySettings[keyof LbrySettings]]>)
.filter(([k]) => settings[k] === null || settings[k] === undefined);
// fix our local var and set it in storage for later
if (invalidEntries.length > 0) {
const changeSet = Object.fromEntries(invalidEntries);
Object.assign(settings, changeSet);
chrome.storage.local.set(changeSet);
}
chrome.browserAction.setBadgeText({ text: settings.enabled ? 'ON' : 'OFF' });
}
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== 'local' || !changes.enabled) return;
chrome.browserAction.setBadgeText({ text: changes.enabled.newValue ? 'ON' : 'OFF' });
});
chrome.runtime.onStartup.addListener(initSettings);
chrome.runtime.onInstalled.addListener(initSettings);

View file

@ -1,67 +0,0 @@
import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url'
import { getSettingsAsync, PlatformName } from '../common/settings'
import { YTDescriptor, ytService } from '../common/yt'
export interface UpdateContext {
descriptor: YTDescriptor
/** LBRY URL fragment */
pathname: string
enabled: boolean
platform: PlatformName
}
async function resolveYT(descriptor: YTDescriptor) {
const lbryProtocolUrl: string | null = await ytService.resolveById(descriptor).then(a => a[0]);
const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true });
if (segments.length === 0) return;
return segments.join('/');
}
const pathnameCache: Record<string, string | undefined> = {};
async function ctxFromURL(url: string): Promise<UpdateContext | void> {
if (!url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
url = new URL(url).href;
const { enabled, platform } = await getSettingsAsync('enabled', 'platform');
const descriptor = ytService.getId(url);
if (!descriptor) return; // couldn't get the ID, so we're done
const res = url in pathnameCache ? pathnameCache[url] : await resolveYT(descriptor);
pathnameCache[url] = res;
if (!res) return; // couldn't find it on lbry, so we're done
return { descriptor, pathname: res, enabled, platform };
}
// handles lbry.tv -> lbry app redirect
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => {
const { enabled, platform } = await getSettingsAsync('enabled', 'platform');
if (!enabled || platform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return;
const url = appRedirectUrl(tabUrl, { encode: true });
if (!url) return;
chrome.tabs.update(tabId, { url });
alert('Opened link in LBRY App!'); // Better for UX since sometimes LBRY App doesn't take focus, if that is fixed, this can be removed
chrome.tabs.executeScript(tabId, {
code: `if (window.history.length === 1) {
window.close();
} else {
window.history.back();
}
document.querySelectorAll('video').forEach(v => v.pause());
`
});
});
chrome.runtime.onMessage.addListener(({ url }: { url: string }, sender, sendResponse) => {
ctxFromURL(url).then(ctx => {
sendResponse(ctx);
})
return true;
})
// relay youtube link changes to the content script
chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => {
if (!changeInfo.url || !url || !(url.startsWith('https://www.youtube.com/watch?v=') || url.startsWith('https://www.youtube.com/channel/'))) return;
ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx));
});

View file

@ -1,174 +1,363 @@
import { PlatformName, platformSettings } from '../common/settings'
import type { UpdateContext } from '../scripts/tabOnUpdated'
import { h, JSX, render } from 'preact'
import { h, render } from 'preact'
import { parseYouTubeURLTimeString } from '../modules/yt'
import type { resolveById, ResolveUrlTypes } from '../modules/yt/urlResolve'
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, getTargetPlatfromSettingsEntiries, SourcePlatform, sourcePlatfromSettings, TargetPlatform, targetPlatformSettings } from '../settings';
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t));
(async () => {
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
interface ButtonSettings {
text: string
icon: string
style?:
{
icon?: JSX.CSSProperties
button?: JSX.CSSProperties
interface Target {
platform: TargetPlatform
lbryPathname: string
type: ResolveUrlTypes
time: number | null
}
}
const buttonSettings: Record<PlatformName, ButtonSettings> = {
app: {
text: 'Watch on LBRY',
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
},
'madiator.com': {
text: 'Watch on',
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
style: {
button: { flexDirection: 'row-reverse' },
icon: { transform: 'scale(1.2)' }
}
},
odysee: {
text: 'Watch on Odysee',
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
},
};
interface Source {
platform: SourcePlatform
id: string
type: ResolveUrlTypes
url: URL
time: number | null
}
interface ButtonParameters
{
platform?: PlatformName
pathname?: string
time?: number
}
const targetPlatforms = getTargetPlatfromSettingsEntiries()
const settings = await getExtensionSettingsAsync()
// Listen Settings Change
chrome.storage.onChanged.addListener(async (changes, areaName) => {
if (areaName !== 'local') return
Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue])))
})
export function WatchOnLbryButton({ platform = 'app', pathname, time }: ButtonParameters) {
if (!pathname || !platform) return null;
const platformSetting = platformSettings[platform];
const buttonSetting = buttonSettings[platform];
const buttonMountPoint = document.createElement('div')
buttonMountPoint.style.display = 'inline-flex'
const url = new URL(`${platformSetting.domainPrefix}${pathname}`)
if (time) url.searchParams.append('t', time.toFixed(0))
const playerButtonMountPoint = document.createElement('div')
playerButtonMountPoint.style.display = 'inline-flex'
return <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
<a href={`${url.toString()}`} onClick={pauseVideo} role='button'
function WatchOnLbryButton({ source, target }: { source?: Source, target?: Target }) {
if (!target || !source) return null
const url = getLbryUrlByTarget(target)
return <div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
borderRadius: '2px',
backgroundColor: platformSetting.theme,
border: '0',
color: 'whitesmoke',
padding: '10px 16px',
marginRight: '5px',
fontSize: '14px',
textDecoration: 'none',
...buttonSetting.style?.button,
}}>
<img src={buttonSetting.icon} height={16}
style={{ transform: 'scale(1.5)', ...buttonSetting.style?.icon }} />
<span>{buttonSetting.text}</span>
display: 'grid',
gridTemplateRows: '36px',
gridAutoColumns: 'auto',
alignContent: 'center'
}}
>
<a href={`${url.href}`} target={target.platform === targetPlatformSettings.app ? '' : '_blank'} role='button'
style={{
display: 'flex',
alignItems: 'center',
gap: '7px',
borderRadius: '2px',
padding: '0 16px',
margin: '0 4px',
fontWeight: 'bold',
border: '0',
color: 'whitesmoke',
fontSize: '14px',
textDecoration: 'none',
backgroundColor: target.platform.theme,
backgroundImage: target.platform.theme,
...target.platform.button.style?.button,
}}
onClick={() => findVideoElementAwait(source).then((videoElement) => {
videoElement.pause()
})}
>
<img src={target.platform.button.icon} height={24} style={{ ...target.platform.button.style?.icon }} />
<span>{target.type === 'channel' ? 'Channel on' : 'Watch on'} {target.platform.button.platformNameText}</span>
</a>
</div>;
}
let mountPoint: HTMLDivElement | null = null
/** Returns a mount point for the button */
async function findButtonMountPoint(): Promise<HTMLDivElement | void> {
let ownerBar = document.querySelector('ytd-video-owner-renderer');
for (let i = 0; !ownerBar && i < 50; i++) {
await sleep(200);
ownerBar = document.querySelector('ytd-video-owner-renderer');
</div>
}
if (!ownerBar) return;
const div = document.createElement('div');
div.style.display = 'flex';
ownerBar.insertAdjacentElement('afterend', div);
function WatchOnLbryPlayerButton({ source, target }: { source?: Source, target?: Target }) {
if (!target || !source) return null
const url = getLbryUrlByTarget(target)
mountPoint = div
}
return <div
style={{
display: 'grid',
gridTemplateRows: '36px',
gridAutoColumns: 'auto',
alignContent: 'center'
}}
>
<a href={`${url.href}`} target={target.platform === targetPlatformSettings.app ? '' : '_blank'} role='button'
style={{
display: 'flex',
alignItems: 'center',
gap: '7px',
borderRadius: '2px',
paddingRight: '10px',
let videoElement: HTMLVideoElement | null = null;
async function findVideoElement() {
while(!(videoElement = document.querySelector('#ytd-player video'))) await sleep(200)
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
}
fontWeight: 'bold',
border: '0',
color: 'whitesmoke',
fontSize: '14px',
textDecoration: 'none',
...target.platform.button.style?.button,
}}
onClick={() => findVideoElementAwait(source).then((videoElement) => {
videoElement.pause()
})}
>
<img src={target.platform.button.icon} height={24} style={{ ...target.platform.button.style?.icon }} />
<span>{target.type === 'channel' ? 'Channel on' : 'Watch on'} {target.platform.button.platformNameText}</span>
</a>
</div>
}
function pauseVideo() { document.querySelectorAll<HTMLVideoElement>('video').forEach(v => v.pause()); }
function openApp(url: string) {
pauseVideo();
location.assign(url);
}
/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */
let ctxCache: UpdateContext | null = null
function handleURLChange (ctx: UpdateContext | null) {
ctxCache = ctx
updateButton(ctx)
if (ctx?.enabled) redirectTo(ctx)
}
function updateButton(ctx: UpdateContext | null) {
if (!mountPoint) return
if (!ctx) return render(<WatchOnLbryButton />, mountPoint)
if (ctx.descriptor.type !== 'video') return;
const time = videoElement?.currentTime ?? 0
const pathname = ctx.pathname
const platform = ctx.platform
render(<WatchOnLbryButton platform={platform} pathname={pathname} time={time} />, mountPoint)
}
function redirectTo({ platform, pathname }: UpdateContext) {
const parseYouTubeTime = (timeString: string) => {
const signs = timeString.replace(/[0-9]/g, '')
if (signs.length === 0) return timeString
const numbers = timeString.replace(/[^0-9]/g, '-').split('-')
let total = 0
for (let i = 0; i < signs.length; i++) {
let t = parseInt(numbers[i])
switch (signs[i]) {
case 'd': t *= 24; case 'h': t *= 60; case 'm': t *= 60; case 's': break
default: return '0'
}
total += t
function updateButtons(params: { source: Source, target: Target } | null): void {
if (!params) {
render(<WatchOnLbryButton />, buttonMountPoint)
render(<WatchOnLbryPlayerButton />, playerButtonMountPoint)
return
}
{
const mountPlayerButtonBefore = settings.buttonVideoPlayer ?
document.querySelector(params.source.platform.htmlQueries.mountPoints.mountPlayerButtonBefore) :
null
if (!mountPlayerButtonBefore) render(<WatchOnLbryPlayerButton />, playerButtonMountPoint)
else {
if (playerButtonMountPoint.getAttribute('data-id') !== params.source.id) {
mountPlayerButtonBefore.parentElement?.insertBefore(playerButtonMountPoint, mountPlayerButtonBefore)
playerButtonMountPoint.setAttribute('data-id', params.source.id)
}
render(<WatchOnLbryPlayerButton target={params.target} source={params.source} />, playerButtonMountPoint)
}
}
{
const mountButtonBefore = settings[(`button${params.source.type[0].toUpperCase() + params.source.type.substring(1)}Sub`) as 'buttonVideoSub' | 'buttonChannelSub'] ?
document.querySelector(params.source.platform.htmlQueries.mountPoints.mountButtonBefore[params.source.type]) :
null
if (!mountButtonBefore) render(<WatchOnLbryButton />, buttonMountPoint)
else {
if (buttonMountPoint.getAttribute('data-id') !== params.source.id) {
mountButtonBefore.parentElement?.insertBefore(buttonMountPoint, mountButtonBefore)
buttonMountPoint.setAttribute('data-id', params.source.id)
}
render(<WatchOnLbryButton target={params.target} source={params.source} />, buttonMountPoint)
}
}
return total.toString()
}
const platformSetting = platformSettings[platform];
const url = new URL(`${platformSetting.domainPrefix}${pathname}`)
const time = new URL(location.href).searchParams.get('t')
if (time) url.searchParams.append('t', parseYouTubeTime(time))
async function findVideoElementAwait(source: Source) {
let videoElement: HTMLVideoElement | null = null
while (!(videoElement = document.querySelector(source.platform.htmlQueries.videoPlayer))) await sleep(200)
return videoElement
}
if (platform === 'app') return openApp(url.toString());
location.replace(url.toString());
}
async function getSourceByUrl(url: URL): Promise<Source | null> {
const platform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname)
if (!platform) return null
if (url.pathname === '/watch' && url.searchParams.has('v')) {
return {
id: url.searchParams.get('v')!,
platform,
time: url.searchParams.has('t') ? parseYouTubeURLTimeString(url.searchParams.get('t')!) : null,
type: 'video',
url
}
}
else if (url.pathname.startsWith('/channel/')) {
return {
id: url.pathname.substring("/channel/".length),
platform,
time: null,
type: 'channel',
url
}
}
else if (url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/')) {
// We have to download the page content again because these parts of the page are not responsive
// yt front end sucks anyway
const content = await (await fetch(location.href)).text()
const prefix = `https://www.youtube.com/feeds/videos.xml?channel_id=`
const suffix = `"`
const startsAt = content.indexOf(prefix) + prefix.length
const endsAt = content.indexOf(suffix, startsAt)
const id = content.substring(startsAt, endsAt)
return {
id,
platform,
time: null,
type: 'channel',
url
}
}
return null
}
async function getTargetsBySources(...sources: Source[]) {
const params: Parameters<typeof requestResolveById>[0] = sources.map((source) => ({ id: source.id, type: source.type }))
const platform = targetPlatformSettings[settings.targetPlatform]
const results = await requestResolveById(params) ?? []
const targets: Record<string, Target | null> = Object.fromEntries(
sources.map((source) => {
const result = results[source.id]
if (!result) return [
source.id,
null
]
return [
source.id,
{
type: result.type,
lbryPathname: result.id,
platform,
time: source.time
}
]
})
)
return targets
}
// We should get this from background, so the caching works and we don't get errors in the future if yt decides to impliment CORS
async function requestResolveById(...params: Parameters<typeof resolveById>): ReturnType<typeof resolveById> {
const response = await new Promise<string | null | 'error'>((resolve) => chrome.runtime.sendMessage({ method: 'resolveUrl', data: JSON.stringify(params) }, resolve))
if (response?.startsWith('error:')) {
console.error("Background error on:", params)
throw new Error(`Background error. ${response ?? ''}`)
}
return response ? JSON.parse(response) : null
}
// Request new tab
async function openNewTab(url: URL) {
chrome.runtime.sendMessage({ method: 'openTab', data: JSON.stringify({ href: url.href }) })
}
function findTargetFromSourcePage(source: Source): Target | null {
const linksContainer =
source.type === 'video' ?
document.querySelector(source.platform.htmlQueries.videoDescription) :
source.platform.htmlQueries.channelLinks ? document.querySelector(source.platform.htmlQueries.channelLinks) : null
if (linksContainer) {
const anchors = Array.from(linksContainer.querySelectorAll<HTMLAnchorElement>('a'))
for (const anchor of anchors) {
if (!anchor.href) continue
const url = new URL(anchor.href)
let lbryURL: URL | null = null
// Extract real link from youtube's redirect link
if (source.platform === sourcePlatfromSettings['youtube.com']) {
if (!targetPlatforms.some(([key, platform]) => url.searchParams.get('q')?.startsWith(platform.domainPrefix))) continue
lbryURL = new URL(url.searchParams.get('q')!)
}
// Just directly use the link itself on other platforms
else {
if (!targetPlatforms.some(([key, platform]) => url.href.startsWith(platform.domainPrefix))) continue
lbryURL = new URL(url.href)
}
if (lbryURL) {
return {
lbryPathname: lbryURL.pathname.substring(1),
time: null,
type: lbryURL.pathname.substring(1).includes('/') ? 'video' : 'channel',
platform: targetPlatformSettings[settings.targetPlatform]
}
}
}
}
return null
}
function getLbryUrlByTarget(target: Target) {
const url = new URL(`${target.platform.domainPrefix}${target.lbryPathname}`)
if (target.time) url.searchParams.set('t', target.time.toFixed(0))
return url
}
// Master Loop
for (
let url = new URL(location.href),
urlHrefCache: string | null = null;
;
urlHrefCache = url.href,
url = new URL(location.href)
) {
await sleep(500)
try {
const source = await getSourceByUrl(new URL(location.href))
if (!source) {
updateButtons(null)
continue
}
const target = (await getTargetsBySources(source))[source.id] ?? findTargetFromSourcePage(source)
if (!target) {
updateButtons(null)
continue
}
findButtonMountPoint().then(() => updateButton(ctxCache))
findVideoElement().then(() => updateButton(ctxCache))
// Update Buttons
if (urlHrefCache !== url.href) updateButtons(null)
// If target is a video target add timestampt to it
if (target.type === 'video') {
const videoElement = document.querySelector<HTMLVideoElement>(source.platform.htmlQueries.videoPlayer)
if (videoElement) target.time = videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null
}
updateButtons({ target, source })
// Redirect
if (
source.type === target.type &&
(
(
settings.redirectVideo &&
source.type === 'video' && !source.url.searchParams.has('list')
) ||
(
settings.redirectVideoPlaylist &&
source.type === 'video' && source.url.searchParams.has('list')
) ||
(
settings.redirectChannel &&
source.type === 'channel'
)
)
) {
if (url.href === urlHrefCache) continue
/** Request UpdateContext from background */
const requestCtxFromUrl = async (url: string) => await new Promise<UpdateContext | null>((resolve) => chrome.runtime.sendMessage({ url }, resolve))
const lbryURL = getLbryUrlByTarget(target)
/** Handle the location on load of the page */
requestCtxFromUrl(location.href).then((ctx) => handleURLChange(ctx))
if (source.type === 'video') {
findVideoElementAwait(source).then((videoElement) => videoElement.pause())
}
/*
* Gets messages from background script which relays tab update events. This is because there's no sensible way to detect
* history.pushState changes from a content script
*/
chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => handleURLChange(ctx));
if (target.platform === targetPlatformSettings.app) {
if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true }))
// Replace is being used so browser doesnt start an empty window
// Its not gonna be able to replace anyway, since its a LBRY Uri
location.replace(lbryURL)
}
else {
openNewTab(lbryURL)
if (window.history.length === 1)
window.close()
else
window.history.back()
}
}
} catch (error) {
console.error(error)
}
}
/** On settings change */
chrome.storage.onChanged.addListener(async (changes, areaName) => {
if (areaName !== 'local') return;
if (changes.platform) handleURLChange(await requestCtxFromUrl(location.href))
});
})()

View file

@ -0,0 +1,2 @@
import './settings/background'
import './scripts/background'

View file

@ -0,0 +1,33 @@
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, setExtensionSetting, targetPlatformSettings, ytUrlResolversSettings } from '../settings'
// This is for manifest v2 and v3
const chromeAction = chrome.action ?? chrome.browserAction
/** Reset settings to default value and update the browser badge text */
async function initSettings() {
let settings = await getExtensionSettingsAsync()
// get all the values that aren't set and use them as a change set
const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof ExtensionSettings, ExtensionSettings[keyof ExtensionSettings]]>)
.filter(([k]) => settings[k] === undefined || settings[k] === null)
// fix our local var and set it in storage for later
if (invalidEntries.length > 0) {
const changeSet = Object.fromEntries(invalidEntries)
chrome.storage.local.set(changeSet)
settings = await getExtensionSettingsAsync()
}
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setExtensionSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setExtensionSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
// chromeAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
}
/* chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== 'local' || !changes.redirect) return
chromeAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' })
}) */
chrome.runtime.onStartup.addListener(initSettings)
chrome.runtime.onInstalled.addListener(initSettings)

201
src/settings/index.ts Normal file
View file

@ -0,0 +1,201 @@
import type { JSX } from "preact"
import { useEffect, useReducer } from "preact/hooks"
import type { ResolveUrlTypes } from "../modules/yt/urlResolve"
export interface ExtensionSettings extends Record<string, string | number | boolean | null | undefined>{
targetPlatform: TargetPlatformName
urlResolver: YTUrlResolverName,
redirectVideo: boolean,
redirectChannel: boolean,
redirectVideoPlaylist: boolean,
buttonVideoSub: boolean
buttonVideoPlayer: boolean
buttonChannelSub: boolean
publicKey: string | null,
privateKey: string | null
}
export const DEFAULT_SETTINGS: ExtensionSettings = {
targetPlatform: 'odysee',
urlResolver: 'odyseeApi',
redirectVideo: false,
redirectChannel: false,
redirectVideoPlaylist: false,
buttonVideoSub: true,
buttonVideoPlayer: true,
buttonChannelSub: true,
privateKey: null,
publicKey: null
}
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
}
/** Utilty to set a setting in the browser */
export const setExtensionSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
/**
* A hook to read the settings from local storage
*
* @param defaultSettings the default value. Must have all relevant keys present and should not change
*/
function useSettings(defaultSettings: ExtensionSettings) {
const [state, dispatch] = useReducer((state, nstate: Partial<ExtensionSettings>) => ({ ...state, ...nstate }), defaultSettings)
const settingsKeys = Object.keys(defaultSettings)
// register change listeners, gets current values, and cleans up the listeners on unload
useEffect(() => {
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== 'local') return
const changeEntries = Object.keys(changes).filter((key) => settingsKeys.includes(key)).map((key) => [key, changes[key].newValue])
if (changeEntries.length === 0) return // no changes; no use dispatching
dispatch(Object.fromEntries(changeEntries))
}
chrome.storage.onChanged.addListener(changeListener)
chrome.storage.local.get(settingsKeys, async (settings) => dispatch(settings))
return () => chrome.storage.onChanged.removeListener(changeListener)
}, [])
return state
}
/** A hook to read watch on lbry settings from local storage */
export const useExtensionSettings = () => useSettings(DEFAULT_SETTINGS)
const targetPlatform = (o: {
domainPrefix: string
displayName: string
theme: string
button: {
platformNameText: string,
icon: string
style?:
{
icon?: JSX.CSSProperties
button?: JSX.CSSProperties
}
}
}) => o
export type TargetPlatform = ReturnType<typeof targetPlatform>
export type TargetPlatformName = Extract<keyof typeof targetPlatformSettings, string>
export const getTargetPlatfromSettingsEntiries = () => {
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
}
export const targetPlatformSettings = {
'madiator.com': targetPlatform({
domainPrefix: 'https://madiator.com/',
displayName: 'Madiator.com',
theme: 'linear-gradient(130deg, #499375, #43889d)',
button: {
platformNameText: '',
icon: chrome.runtime.getURL('assets/icons/lbry/madiator-logo.svg'),
style: {
button: { flexDirection: 'row-reverse' },
icon: { }
}
}
}),
odysee: targetPlatform({
domainPrefix: 'https://odysee.com/',
displayName: 'Odysee',
theme: 'linear-gradient(130deg, #c63d59, #f77937)',
button: {
platformNameText: 'Odysee',
icon: chrome.runtime.getURL('assets/icons/lbry/odysee-logo.svg')
}
}),
app: targetPlatform({
domainPrefix: 'lbry://',
displayName: 'LBRY App',
theme: 'linear-gradient(130deg, #499375, #43889d)',
button: {
platformNameText: 'LBRY',
icon: chrome.runtime.getURL('assets/icons/lbry/lbry-logo.svg')
}
}),
}
const sourcePlatform = (o: {
hostnames: string[]
htmlQueries: {
mountPoints: {
mountButtonBefore: Record<ResolveUrlTypes, string>,
mountPlayerButtonBefore: string,
}
videoPlayer: string,
videoDescription: string
channelLinks: string
}
}) => o
export type SourcePlatform = ReturnType<typeof sourcePlatform>
export type SourcePlatformName = Extract<keyof typeof sourcePlatfromSettings, string>
export function getSourcePlatfromSettingsFromHostname(hostname: string) {
const values = Object.values(sourcePlatfromSettings)
for (const settings of values)
if (settings.hostnames.includes(hostname)) return settings
return null
}
export const sourcePlatfromSettings = {
"youtube.com": sourcePlatform({
hostnames: ['www.youtube.com'],
htmlQueries: {
mountPoints: {
mountButtonBefore: {
video: 'ytd-video-owner-renderer~#subscribe-button',
channel: '#channel-header-container #buttons #subscribe-button'
},
mountPlayerButtonBefore: 'ytd-watch-flexy ytd-player .ytp-right-controls',
},
videoPlayer: '#ytd-player video',
videoDescription: 'ytd-video-secondary-info-renderer #description',
channelLinks: '#channel-header #links-holder'
}
}),
"yewtu.be": sourcePlatform({
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
htmlQueries: {
mountPoints: {
mountButtonBefore:
{
video: '#subscribe',
channel: '#subscribe'
},
mountPlayerButtonBefore: '#player-container ~ .h-box > h1 > a',
},
videoPlayer: '#player-container video',
videoDescription: '#descriptionWrapper',
channelLinks: '#descriptionWrapper'
}
})
}
const ytUrlResolver = (o: {
name: string
href: string
signRequest: boolean
}) => o
export type YTUrlResolver = ReturnType<typeof ytUrlResolver>
export type YTUrlResolverName = Extract<keyof typeof ytUrlResolversSettings, string>
export const getYtUrlResolversSettingsEntiries = () => Object.entries(ytUrlResolversSettings) as any as [Extract<keyof typeof ytUrlResolversSettings, string>, YTUrlResolver][]
export const ytUrlResolversSettings = {
odyseeApi: ytUrlResolver({
name: "Odysee",
href: "https://api.odysee.com/yt",
signRequest: false
}),
madiatorFinder: ytUrlResolver({
name: "Madiator Finder",
href: "https://finder.madiator.com/api/v1",
signRequest: true
}),
/* madiatorFinderLocal: ytUrlResolver({
name: "Madiator Finder Local",
href: "http://127.0.0.1:3001/api/v1",
signRequest: true
}) */
}

View file

@ -1,6 +0,0 @@
# Getting your subscription data
1. Go to https://takeout.google.com/settings/takeout
2. Deselect everything except `YouTube and YouTube Music` and within that only select `subscriptions`
3. Go through the process and create the export
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension

View file

@ -1,88 +0,0 @@
body {
--color-text: whitesmoke;
--color-backround: rgb(28, 31, 34);
--color-card: #2a2e32;
--color-primary: rgb(43, 187, 144);
background-color: var(--color-backround);
color: var(--color-text);
font-size: 1rem;
}
a {
color: var(--color-primary);
}
h1, h2, h3, h4, h5, h6 {
margin: 0 0 10px;
}
.container {
display: block;
text-align: center;
margin: auto;
width: 50%;
margin-bottom: 15px;
border: 3px;
padding: 10px;
}
.YTtoLBRY {
display: flex;
justify-content: space-around;
flex-direction: row;
}
.Conversion {
margin: 1rem;
width: 45em;
}
.ConversionHelp {
display: flex;
flex-direction: column;
align-items: center;
}
.ConversionCard {
box-shadow: 0 0 0 1px rgba(16,22,26,.1), 0 1px 1px rgba(16,22,26,.2), 0 2px 6px rgba(16,22,26,.2);
border-radius: 5px;
background-color: var(--color-card);
padding: 20px;
}
.btn {
color: var(--color-text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 5px 10px;
border-radius: 3px;
font-size: 14px;
min-width: 30px;
min-height: 30px;
}
.btn.btn-primary {
background-color: var(--color-primary);
}
.btn:disabled {
background-color: rgba(200, 200, 200, .5);
color: rgba(90, 90, 90, .6);
cursor: not-allowed;
}
@media (max-width: 1400px) {
.YTtoLBRY {
flex-direction: column;
}
.Conversion {
width: auto;
}
}

View file

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Subscription Converter</title>
<link rel="stylesheet" href="YTtoLBRY.css" />
<script src="YTtoLBRY.tsx" charset="utf-8" defer></script>
</head>
<body>
<div id="root" />
</body>
</html>

View file

@ -1,64 +0,0 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { getSettingsAsync, platformSettings } from '../common/settings'
import { getFileContent, ytService } from '../common/yt'
import readme from './README.md'
/**
* Parses the subscription file and queries the API for lbry channels
*
* @param file to read
* @returns a promise with the list of channels that were found on lbry
*/
async function lbryChannelsFromFile(file: File) {
const ext = file.name.split('.').pop()?.toLowerCase();
const ids = new Set((
ext === 'xml' || ext == 'opml' ? ytService.readOpml :
ext === 'csv' ? ytService.readCsv :
ytService.readJson)(await getFileContent(file)))
const lbryUrls = await ytService.resolveById(...Array.from(ids).map(id => ({ id, type: 'channel' } as const)));
const { platform } = await getSettingsAsync('platform');
const urlPrefix = platformSettings[platform].domainPrefix;
return lbryUrls.map(channel => urlPrefix + channel);
}
function ConversionCard({ onSelect }: { onSelect(file: File): Promise<void> | void }) {
const [file, setFile] = useState(null as File | null);
const [isLoading, setLoading] = useState(false);
return <div className='ConversionCard'>
<h2>Select YouTube Subscriptions</h2>
<div style={{ marginBottom: 10 }}>
<input type='file' onChange={e => setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />
</div>
<button class='btn btn-primary' children='Start Conversion!' disabled={!file || isLoading} onClick={async () => {
if (!file) return;
setLoading(true);
await onSelect(file);
setLoading(false);
}} />
</div>
}
function YTtoLBRY() {
const [lbryChannels, setLbryChannels] = useState([] as string[]);
return <div className='YTtoLBRY'>
<div className='Conversion'>
<ConversionCard onSelect={async file => setLbryChannels(await lbryChannelsFromFile(file))} />
<ul>
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
</ul>
</div>
<div className='ConversionHelp'>
<iframe width='712px' height='400px' allowFullScreen
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
<section dangerouslySetInnerHTML={{ __html: readme }} />
</div>
</div>
}
render(<YTtoLBRY />, document.getElementById('root')!);

View file

@ -41,6 +41,7 @@
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
"importsNotUsedAsValues": "error", /* Import types always with `import type` */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
@ -53,7 +54,6 @@
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */