Merge pull request #116 from DeepDoge/master

Organized Files, Remade YTtoLBRY page, And some refactoring
This commit is contained in:
kodxana 2022-05-01 17:51:53 +02:00 committed by GitHub
commit cf85b39d6a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 413 additions and 627 deletions

View file

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

View file

@ -5,8 +5,8 @@
"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\"",
"watch:parcel": "parcel watch --no-hmr --no-source-maps \"src/**/*.{js,jsx,ts,tsx}\" \"src/**/*.html\"",
"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",

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

@ -10,10 +10,17 @@
}
:root {
font-size: .95rem;
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;
@ -24,7 +31,7 @@ body {
body::before {
content: "";
position: absolute;
position: fixed;
inset: 0;
background: rgba(19, 19, 19, 0.75);
}
@ -36,6 +43,30 @@ body::before {
position: relative;
}
a {
cursor: pointer;
}
p,
ul,
ol,
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
.options {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
justify-content: center;
gap: .25em;
padding: 0 1.5em;
}
.button {
display: inline-flex;
@ -48,14 +79,9 @@ body::before {
color: var(--color-light);
border-radius: .5em;
}
border: unset;
.filled {
background: var(--color-gradient-0);
background-clip: text;
-webkit-background-clip: text;
font-weight: bold;
color: transparent;
font-size: inherit;
}
.button.active {
@ -77,13 +103,36 @@ body::before {
pointer-events: none;
}
.options {
.filled {
background: var(--color-gradient-0);
background-clip: text;
-webkit-background-clip: text;
font-weight: bold;
color: transparent;
}
.error {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
justify-content: center;
gap: .25em;
padding: 0 1.5em;
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 {

View file

@ -1,21 +0,0 @@
@import '../style'
.ButtonRadio
display: flex
justify-content: center
flex-wrap: wrap
gap: .25em
cursor: pointer
*
cursor: pointer
.radio-button
@extend .button
.radio-button.checked
@extend .button.active
input[type="radio"]
opacity: 0
position: absolute

View file

@ -1,31 +0,0 @@
import classnames from 'classnames'
import { h } from 'preact'
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,33 +0,0 @@
$background-color: #191a1c !default
$text-color: whitesmoke !default
$btn-color: #075656 !default
$btn-select: teal !default
body
width: 30em
text-align: center
background-color: $background-color
color: $text-color
font-family: sans-serif
padding: 1em
.container
display: block
text-align: center
.button
border-radius: .5em
background-color: $btn-color
border: .2em solid $btn-color
color: $text-color
font-size: 0.8rem
font-weight: 400
padding: .5em
text-align: center
&.active
border-color: $btn-select
&:focus
outline: none

View file

@ -1,35 +0,0 @@
import { useEffect, useReducer } from 'preact/hooks'
import { DEFAULT_SETTINGS, ExtensionSettings } from './settings'
/**
* 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
}
/** Utilty to set a setting in the browser */
export const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
/** A hook to read watch on lbry settings from local storage */
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)

View file

@ -1,32 +0,0 @@
import crypto from 'crypto'
function generateKeys() {
// The `generateKeyPairSync` method accepts two arguments:
// 1. The type ok keys we want, which in this case is "rsa"
// 2. An object with the properties of the key
const keys = crypto.generateKeyPairSync("rsa", {
// The standard secure default length for RSA keys is 2048 bits
modulusLength: 2048,
})
return keys
}
function encodeKeys(keys: { publicKey: Buffer, privateKey: Buffer }) {
return JSON.stringify({ publicKey: keys.publicKey.toString('base64'), privateKey: keys.privateKey.toString('base64') })
}
function decodeKeys(encodedKeys: string) {
const keysBase64 = JSON.parse(encodedKeys)
return {
publicKey: Buffer.from(keysBase64.publicKey),
privateKey: Buffer.from(keysBase64.privateKey)
}
}
function sign(data: string, privateKey: Buffer) {
return crypto.sign("sha256", Buffer.from(data), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
})
}

View file

@ -0,0 +1,33 @@
import { ComponentChildren, h } from 'preact'
import './style.css'
/*
Re-implementation of https://github.com/DeepDoge/svelte-responsive-row
*/
export function Row(params: {
children: ComponentChildren
type?: "fit" | "fill",
idealSize?: string,
gap?: string,
maxColumnCount?: number,
justifyItems?: "center" | "start" | "end" | "stretch"
}) {
if (!params.type) params.type = 'fill'
if (!params.gap) params.gap = '0'
if (!params.idealSize) params.idealSize = '100%'
if (!params.maxColumnCount) params.maxColumnCount = Number.MAX_SAFE_INTEGER
if (!params.justifyItems) params.justifyItems = 'center'
return <div className='responsive-row'
style={{
'--ideal-size': params.idealSize,
'--gap': params.gap === "0" ? "0px" : params.gap,
'--type': `auto-${params.type}`,
'--max-column-count': params.maxColumnCount,
'--justify-items': params.justifyItems
}}
>
{params.children}
</div>
}

View file

@ -0,0 +1,6 @@
.responsive-row {
display: grid;
grid-template-columns: repeat(var(--type), minmax(min(max(100% / var(--max-column-count) - var(--gap), var(--ideal-size)), 100%), 1fr));
gap: var(--gap);
justify-items: var(--justify-items);
}

View file

@ -31,26 +31,26 @@
],
"background": {
"scripts": [
"scripts/storageSetup.js",
"settings/background.js",
"scripts/background.js"
],
"persistent": true
},
"browser_action": {
"default_title": "Watch on LBRY",
"default_popup": "popup/popup.html"
"default_popup": "pages/popup/index.html"
},
"web_accessible_resources": [
"popup.html",
"tools/YTtoLBRY.html",
"icons/lbry/lbry-logo.svg",
"icons/lbry/odysee-logo.svg",
"icons/lbry/madiator-logo.svg"
"pages/popup/index.html",
"pages/YTtoLBRY/index.html",
"assets/icons/lbry/lbry-logo.svg",
"assets/icons/lbry/odysee-logo.svg",
"assets/icons/lbry/madiator-logo.svg"
],
"icons": {
"16": "icons/wol/icon16.png",
"48": "icons/wol/icon48.png",
"128": "icons/wol/icon128.png"
"16": "assets/icons/wol/icon16.png",
"48": "assets/icons/wol/icon48.png",
"128": "assets/icons/wol/icon128.png"
},
"manifest_version": 2
}
}

View file

@ -1,6 +1,5 @@
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "./settings"
import { setSetting } from "./useSettings"
import path from 'path'
import { getExtensionSettingsAsync, setExtensionSetting, ytUrlResolversSettings } from "../../settings"
async function generateKeys() {
const keys = await window.crypto.subtle.generateKey(
@ -38,7 +37,6 @@ async function exportPublicKey(key: CryptoKey) {
key
)
const publicKey = Buffer.from(exported).toString('base64')
console.log(publicKey)
return publicKey.substring(publicKeyPrefix.length, publicKeyPrefix.length + publicKeyLength)
}
@ -64,8 +62,8 @@ export async function sign(data: string, privateKey: string) {
}
export function resetProfileSettings() {
setSetting('publicKey', null)
setSetting('privateKey', null)
setExtensionSetting('publicKey', null)
setExtensionSetting('privateKey', null)
}
async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: string, data: T) {
@ -73,9 +71,7 @@ async function apiRequest<T extends object>(method: 'GET' | 'POST', pathname: st
/* const urlResolverSettings = ytUrlResolversSettings[settings.urlResolver]
if (!urlResolverSettings.signRequest) throw new Error() */
console.log(ytUrlResolversSettings)
const url = new URL(ytUrlResolversSettings.madiatorFinder.href/* urlResolverSettings.href */)
console.log(url)
url.pathname = path.join(url.pathname, pathname)
url.searchParams.set('data', JSON.stringify(data))
@ -113,8 +109,8 @@ export async function generateProfileAndSetNickname(overwrite = false) {
publicKey = keys.publicKey
privateKey = keys.privateKey
})
setSetting('publicKey', publicKey)
setSetting('privateKey', privateKey)
setExtensionSetting('publicKey', publicKey)
setExtensionSetting('privateKey', privateKey)
}
await apiRequest('POST', '/profile', { nickname })
alert(`Your nickname has been set to ${nickname}`)
@ -198,8 +194,8 @@ export async function importProfileKeysFromFile() {
const json = await readFile()
if (!json) throw new Error("Invalid")
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
setSetting('publicKey', publicKey)
setSetting('privateKey', privateKey)
setExtensionSetting('publicKey', publicKey)
setExtensionSetting('privateKey', privateKey)
} catch (error: any) {
alert(error.message)
}

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)
})
}

View file

@ -12,23 +12,6 @@ interface YtExportedJsonSubscription {
}
/**
* @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)
})
}
/**
* Reads the array of YT channels from an OPML file
*

View file

@ -1,8 +1,8 @@
import { chunk } from "lodash"
import { sign } from "../crypto"
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings"
import { LbryPathnameCache } from "./urlCache"
import path from "path"
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../../settings"
import { sign } from "../crypto"
import { LbryPathnameCache } from "./urlCache"
const QUERY_CHUNK_SIZE = 100
@ -11,8 +11,10 @@ type Results = Record<string, YtUrlResolveItem>
type Paramaters = YtUrlResolveItem[]
interface ApiResponse {
channels?: Record<string, string>
videos?: Record<string, string>
data: {
channels?: Record<string, string>
videos?: Record<string, string>
}
}
export async function resolveById(params: Paramaters, progressCallback?: (progress: number) => void): Promise<Results> {
@ -37,7 +39,6 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
// No cache found
return item
}))).filter((o) => o) as Paramaters
console.log(params)
if (params.length === 0) return results
@ -54,9 +55,8 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
if (apiResponse.ok) {
const response: ApiResponse = await apiResponse.json()
for (const item of params)
{
const lbryUrl = ((item.type === 'channel' ? response.channels : response.videos) ?? {})[item.id]?.replaceAll('#', ':') ?? null
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 LbryPathnameCache.put(lbryUrl, item.id)
@ -76,7 +76,6 @@ export async function resolveById(params: Paramaters, progressCallback?: (progre
if (progressCallback) progressCallback(++i / (chunks.length + 1))
Object.assign(results, await requestChunk(chunk))
}
console.log(results)
if (progressCallback) progressCallback(1)
return results

View file

@ -3,4 +3,4 @@
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
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

@ -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,80 @@
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'
import readme from './README.md'
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://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
<section dangerouslySetInnerHTML={{ __html: readme }} />
</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>

View file

@ -1,11 +1,8 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import '../common/common.css'
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../common/crypto'
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries } from '../common/settings'
import { setSetting, useLbrySettings } from '../common/useSettings'
import { LbryPathnameCache } from '../common/yt/urlCache'
import './popup.css'
import { exportProfileKeysAsFile, friendlyPublicKey, generateProfileAndSetNickname, getProfile, importProfileKeysFromFile, purgeProfile, resetProfileSettings } from '../../modules/crypto'
import { LbryPathnameCache } from '../../modules/yt/urlCache'
import { getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, setExtensionSetting, useExtensionSettings } from '../../settings'
/** Gets all the options for redirect destinations as selection options */
@ -13,7 +10,7 @@ const targetPlatforms = getTargetPlatfromSettingsEntiries()
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings()
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useExtensionSettings()
let [loading, updateLoading] = useState(() => false)
let [popupRoute, updateRoute] = useState<string | null>(() => null)
@ -40,21 +37,22 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
<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>
<section>
{
popupRoute === 'profile'
? <a onClick={() => updateRoute('')} className="button filled"> Back</a>
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
? <a onClick={() => updateRoute('')} className="filled"> Back</a>
: <a className='filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
}
</section>
</header>
: <header>
{
popupRoute === 'profile'
? <a onClick={() => updateRoute('')} className="button filled"> Back</a>
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Your Profile</a>
}
popupRoute === 'profile'
? <a onClick={() => updateRoute('')} className="filled"> Back</a>
: <a className='filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
}
</header>
}
{
@ -123,10 +121,10 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
<section>
<label>Pick a mode:</label>
<div className='options'>
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
<a onClick={() => setExtensionSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
Redirect
</a>
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
<a onClick={() => setExtensionSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
Show a button
</a>
</div>
@ -135,7 +133,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
<label>Which platform you would like to redirect?</label>
<div className='options'>
{targetPlatforms.map(([name, value]) =>
<a onClick={() => setSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
<a onClick={() => setExtensionSetting('targetPlatform', name)} className={`button ${targetPlatform === name ? 'active' : ''}`}>
{value.displayName}
</a>
)}
@ -145,7 +143,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
<label>Which resolver API you want to use?</label>
<div className='options'>
{ytUrlResolverOptions.map(([name, value]) =>
<a onClick={() => setSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
<a onClick={() => setExtensionSetting('urlResolver', name)} className={`button ${urlResolver === name ? 'active' : ''}`}>
{value.name}
</a>
)}
@ -156,7 +154,7 @@ function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfil
</section>
<section>
<label>Tools</label>
<a target='_blank' href='/tools/YTtoLBRY.html' className={`button filled`}>
<a target='_blank' href='/tools/YTtoLBRY/index.html' className={`filled`}>
Subscription Converter
</a>
</section>

View file

@ -1,18 +1,3 @@
label {
font-size: 1.75em;
font-weight: bold;
text-align: center;
}
a {
cursor: pointer;
}
p {
margin: 0;
text-align: center;
}
header {
display: grid;
gap: .5em;
@ -34,6 +19,12 @@ section {
gap: .75em;
}
section label {
font-size: 1.75em;
font-weight: bold;
text-align: center;
}
#popup {
width: 35em;
max-width: 100%;

View file

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

View file

@ -1,4 +1,4 @@
import { resolveById } from '../common/yt/urlResolve'
import { resolveById } from "../modules/yt/urlResolve"
const onGoingLbryPathnameRequest: Record<string, ReturnType<typeof resolveById>> = {}

View file

@ -1,7 +1,7 @@
import { h, render } from 'preact'
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings'
import { parseYouTubeURLTimeString } from '../common/yt'
import { resolveById } from '../common/yt/urlResolve'
import { parseYouTubeURLTimeString } from '../modules/yt'
import { resolveById } from '../modules/yt/urlResolve'
import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../settings'
const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t))
@ -111,14 +111,13 @@ async function requestResolveById(...params: Parameters<typeof resolveById>): Re
const videoId = url.searchParams.get('v')!
const result = await requestResolveById([{ id: videoId, type: 'video' }])
const target: Target | null = result?.[videoId] ? { lbryPathname: result[videoId].id, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null
return target
}
else if (url.pathname.startsWith('/channel/')) {
await requestResolveById([{ id: url.pathname.substring("/channel/".length), type: 'channel' }])
}
else if (url.pathname.startsWith('/c/') || url.pathname.startsWith('/user/'))
{
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()

View file

@ -1,5 +1,4 @@
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, sourcePlatfromSettings, targetPlatformSettings, ytUrlResolversSettings } from '../common/settings'
import { setSetting } from '../common/useSettings'
import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync, setExtensionSetting, targetPlatformSettings, ytUrlResolversSettings } from '.'
/** Reset settings to default value and update the browser badge text */
async function initSettings() {
@ -16,8 +15,8 @@ async function initSettings() {
settings = await getExtensionSettingsAsync()
}
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
if (!Object.keys(targetPlatformSettings).includes(settings.targetPlatform)) setExtensionSetting('targetPlatform', DEFAULT_SETTINGS.targetPlatform)
if (!Object.keys(ytUrlResolversSettings).includes(settings.urlResolver)) setExtensionSetting('urlResolver', DEFAULT_SETTINGS.urlResolver)
chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' })
}

View file

@ -1,4 +1,5 @@
import { JSX } from "preact"
import { useEffect, useReducer } from "preact/hooks"
export interface ExtensionSettings {
redirect: boolean
@ -20,6 +21,39 @@ 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
@ -46,7 +80,7 @@ export const targetPlatformSettings = {
theme: '#075656',
button: {
text: 'Watch on',
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
icon: chrome.runtime.getURL('assets/icons/lbry/madiator-logo.svg'),
style: {
button: { flexDirection: 'row-reverse' },
icon: { transform: 'scale(1.2)' }
@ -59,7 +93,7 @@ export const targetPlatformSettings = {
theme: '#1e013b',
button: {
text: 'Watch on Odysee',
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
icon: chrome.runtime.getURL('assets/icons/lbry/odysee-logo.svg')
}
}),
app: targetPlatform({
@ -68,7 +102,7 @@ export const targetPlatformSettings = {
theme: '#075656',
button: {
text: 'Watch on LBRY',
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
icon: chrome.runtime.getURL('assets/icons/lbry/lbry-logo.svg')
}
}),
}

View file

@ -1,93 +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;
}
.progress-text {
font-weight: bold;
padding: .25em 0;
}
.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,15 +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,70 +0,0 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import { getExtensionSettingsAsync, targetPlatformSettings } from '../common/settings'
import { getFileContent, getSubsFromCsv, getSubsFromJson, getSubsFromOpml } from '../common/yt'
import { resolveById } from '../common/yt/urlResolve'
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' ? getSubsFromOpml :
ext === 'csv' ? getSubsFromCsv :
getSubsFromJson)(await getFileContent(file)))
const items = await resolveById(
Array.from(ids).map(id => ({ id, type: 'channel' } as const)),
(progress) => render(<YTtoLBRY progress={progress} />, document.getElementById('root')!))
const { targetPlatform: platform } = await getExtensionSettingsAsync()
const urlPrefix = targetPlatformSettings[platform].domainPrefix
return Object.values(items).map((item) => urlPrefix + item.id)
}
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {
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 class="progress-text">
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
</div>
</div>
}
function YTtoLBRY({ progress }: { progress: number }) {
const [lbryChannels, setLbryChannels] = useState([] as string[])
return <div className='YTtoLBRY'>
<div className='Conversion'>
<ConversionCard progress={progress} 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 progress={0} />, document.getElementById('root')!)