Merge pull request #113 from DeepDoge/master

New UI and Madiator Finder Features
This commit is contained in:
kodxana 2022-04-30 15:23:51 +02:00 committed by GitHub
commit 53eb4a0c8e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 768 additions and 286 deletions

61
src/common/common.css Normal file
View file

@ -0,0 +1,61 @@
: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;
}
:root {
letter-spacing: .2ch;
}
body {
background: linear-gradient(to left top, var(--color-master), var(--color-slave));
background-attachment: fixed;
color: var(--color-light);
padding: 0;
margin: 0;
}
*,
*::before,
*::after {
box-sizing: border-box;
position: relative;
}
.button {
display: inline-flex;
justify-content: center;
align-items: center;
padding: .5em 1em;
background: var(--color-dark);
color: var(--color-light);
cursor: pointer;
border-radius: .5em;
}
.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;
}

211
src/common/crypto.ts Normal file
View file

@ -0,0 +1,211 @@
import { getExtensionSettingsAsync } from "./settings"
import { setSetting } from "./useSettings"
async function generateKeys() {
const keys = await window.crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
// Consider using a 4096-bit key for systems that require long-term security
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["sign", "verify"]
)
return {
publicKey: await exportPublicKey(keys.publicKey),
privateKey: await exportPrivateKey(keys.privateKey)
}
}
async function exportPrivateKey(key: CryptoKey) {
const exported = await window.crypto.subtle.exportKey(
"pkcs8",
key
)
return Buffer.from(exported).toString('base64')
}
async function exportPublicKey(key: CryptoKey) {
const exported = await window.crypto.subtle.exportKey(
"spki",
key
)
return Buffer.from(exported).toString('base64')
}
function importPrivateKey(base64: string) {
return window.crypto.subtle.importKey(
"pkcs8",
Buffer.from(base64, 'base64'),
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["sign"]
)
}
export async function sign(data: string, privateKey: string) {
return Buffer.from(await window.crypto.subtle.sign(
{ name: "RSASSA-PKCS1-v1_5" },
await importPrivateKey(privateKey),
Buffer.from(data)
)).toString('base64')
}
export function resetProfileSettings() {
setSetting('publicKey', null)
setSetting('privateKey', null)
}
export async function generateProfileAndSetNickname(overwrite = false) {
let { publicKey, privateKey } = await getExtensionSettingsAsync()
let nickname
while (true) {
nickname = prompt("Pick a nickname")
if (nickname) break
if (nickname === null) return
alert("Invalid nickname")
}
if (overwrite || !privateKey || !publicKey) {
resetProfileSettings()
await generateKeys().then((keys) => {
publicKey = keys.publicKey
privateKey = keys.privateKey
})
}
const url = new URL('https://finder.madiator.com/api/v1/profile')
url.searchParams.set('data', JSON.stringify({ nickname }))
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), privateKey!),
publicKey
}))
const respond = await fetch(url.href, { method: "POST" })
if (respond.ok) {
setSetting('publicKey', publicKey)
setSetting('privateKey', privateKey)
alert(`Your nickname has been set to ${nickname}`)
}
else alert((await respond.json()).message)
}
export async function purgeProfile() {
try {
if (!confirm("This will purge all of your online and offline profile data.\nStill wanna continue?")) return
const settings = await getExtensionSettingsAsync()
if (!settings.privateKey || !settings.publicKey)
throw new Error('There is no profile to be purged.')
const url = new URL('https://finder.madiator.com/api/v1/profile/purge')
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), settings.privateKey!),
publicKey: settings.publicKey
}))
const respond = await fetch(url.href, { method: "POST" })
if (respond.ok) {
resetProfileSettings()
alert(`Your profile has been purged`)
}
else throw new Error((await respond.json()).message)
} catch (error: any) {
alert(error.message)
}
}
export async function getProfile() {
try {
const settings = await getExtensionSettingsAsync()
if (!settings.privateKey || !settings.publicKey)
throw new Error('There is no profile.')
const url = new URL('https://finder.madiator.com/api/v1/profile')
url.searchParams.set('data', JSON.stringify({ publicKey: settings.publicKey }))
url.searchParams.set('keys', JSON.stringify({
signature: await sign(url.searchParams.toString(), settings.privateKey!),
publicKey: settings.publicKey
}))
const respond = await fetch(url.href, { method: "GET" })
if (respond.ok) {
const profile = await respond.json() as { nickname: string, score: number, publickKey: string }
return profile
}
else throw new Error((await respond.json()).message)
} catch (error: any) {
console.error(error)
}
}
export function friendlyPublicKey(publicKey: string | null) {
// This is copy paste of Madiator Finder's friendly public key
return publicKey?.substring(publicKey.length - 64, publicKey.length - 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)
})
}
async function readFile() {
return await new Promise<string | null>((resolve) => {
const input = document.createElement("input")
input.type = 'file'
input.accept = '.wol-keys.json'
input.click()
input.addEventListener("change", () => {
if (!input.files?.[0]) return
const myFile = input.files[0]
const reader = new FileReader()
reader.addEventListener('load', () => resolve(reader.result?.toString() ?? null))
reader.readAsBinaryString(myFile)
})
})
}
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() {
try {
const json = await readFile()
if (!json) throw new Error("Invalid")
const { publicKey, privateKey } = JSON.parse(json) as ExportedProfileKeysFile
setSetting('publicKey', publicKey)
setSetting('privateKey', privateKey)
} catch (error: any) {
alert(error.message)
}
}

View file

@ -4,17 +4,23 @@ export interface ExtensionSettings {
redirect: boolean
targetPlatform: TargetPlatformName
urlResolver: YTUrlResolverName
publicKey: string | null,
privateKey: string | null
}
export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' }
export const DEFAULT_SETTINGS: ExtensionSettings = {
redirect: true,
targetPlatform: 'odysee',
urlResolver: 'odyseeApi',
privateKey: null,
publicKey: null
}
export function getExtensionSettingsAsync(): Promise<ExtensionSettings> {
return new Promise(resolve => chrome.storage.local.get(o => resolve(o as any)))
}
export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app'
export interface TargetPlatform {
const targetPlatform = (o: {
domainPrefix: string
displayName: string
theme: string
@ -27,10 +33,14 @@ export interface TargetPlatform {
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: Record<TargetPlatformName, TargetPlatform> = {
'madiator.com': {
export const targetPlatformSettings = {
'madiator.com': targetPlatform({
domainPrefix: 'https://madiator.com/',
displayName: 'Madiator.com',
theme: '#075656',
@ -42,8 +52,8 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
icon: { transform: 'scale(1.2)' }
}
}
},
odysee: {
}),
odysee: targetPlatform({
domainPrefix: 'https://odysee.com/',
displayName: 'Odysee',
theme: '#1e013b',
@ -51,8 +61,8 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
text: 'Watch on Odysee',
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
}
},
app: {
}),
app: targetPlatform({
domainPrefix: 'lbry://',
displayName: 'LBRY App',
theme: '#075656',
@ -60,118 +70,60 @@ export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform>
text: 'Watch on LBRY',
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
}
},
}
export const getTargetPlatfromSettingsEntiries = () => {
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, TargetPlatform][]
}),
}
export type SourcePlatformName = 'youtube.com' | 'yewtu.be'
export interface SourcePlatform {
const sourcePlatform = (o: {
hostnames: string[]
htmlQueries: {
mountButtonBefore: string,
videoPlayer: string
}
}
export const sourcePlatfromSettings: Record<SourcePlatformName, SourcePlatform> = {
"yewtu.be": {
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
htmlQueries: {
mountButtonBefore: '#watch-on-youtube',
videoPlayer: '#player-container video'
}
},
"youtube.com": {
hostnames: ['www.youtube.com'],
htmlQueries: {
mountButtonBefore: 'ytd-video-owner-renderer~#subscribe-button',
videoPlayer: '#ytd-player video'
}
}
}
}) => 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 type YTUrlResolverName = 'lbryInc' | 'madiatorScrap'
export const Keys = Symbol('keys')
export const Values = Symbol('values')
export const SingleValueAtATime = Symbol()
export type YtUrlResolveResponsePath = (string | number | typeof Keys | typeof Values)[]
export interface YtUrlResolveFunction {
pathname: string
defaultParams: Record<string, string | number>
valueParamName: string
paramArraySeperator: string | typeof SingleValueAtATime
responsePath: YtUrlResolveResponsePath
export const sourcePlatfromSettings = {
"yewtu.be": sourcePlatform({
hostnames: ['yewtu.be', 'vid.puffyan.us', 'invidio.xamh.de', 'invidious.kavin.rocks'],
htmlQueries: {
mountButtonBefore: '#watch-on-youtube',
videoPlayer: '#player-container video'
}
}),
"youtube.com": sourcePlatform({
hostnames: ['www.youtube.com'],
htmlQueries: {
mountButtonBefore: 'ytd-video-owner-renderer~#subscribe-button',
videoPlayer: '#ytd-player video'
}
})
}
export interface YTUrlResolver {
const ytUrlResolver = (o: {
name: string
hostname: string
functions: {
getChannelId: YtUrlResolveFunction
getVideoId: YtUrlResolveFunction
}
}
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
lbryInc: {
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",
hostname: "api.odysee.com",
functions: {
getChannelId: {
pathname: "/yt/resolve",
defaultParams: {},
valueParamName: "channel_ids",
paramArraySeperator: ',',
responsePath: ["data", "channels", Values]
},
getVideoId: {
pathname: "/yt/resolve",
defaultParams: {},
valueParamName: "video_ids",
paramArraySeperator: ",",
responsePath: ["data", "videos", Values]
}
}
},
madiatorScrap: {
name: "Madiator.com",
hostname: "scrap.madiator.com",
functions: {
getChannelId: {
pathname: "/api/get-lbry-channel",
defaultParams: {
v: 2
},
valueParamName: "url",
paramArraySeperator: SingleValueAtATime,
responsePath: ["lbrych"]
},
getVideoId: {
pathname: "/api/get-lbry-video",
defaultParams: {
v: 2
},
valueParamName: "url",
paramArraySeperator: SingleValueAtATime,
responsePath: ["lbryurl"]
}
}
}
}
export const getYtUrlResolversSettingsEntiries = () => {
return Object.entries(ytUrlResolversSettings) as any as [Extract<keyof typeof ytUrlResolversSettings, string>, YTUrlResolver][]
href: "https://api.odysee.com/yt/resolve",
signRequest: false
}),
madiatorFinder: ytUrlResolver({
name: "Madiator Finder",
href: "https://finder.madiator.com/api/v1/resolve",
signRequest: true
})
}

View file

@ -1,30 +1,35 @@
import { useEffect, useReducer } from 'preact/hooks'
import { DEFAULT_SETTINGS } from './settings'
import { DEFAULT_SETTINGS, ExtensionSettings } 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
* @param defaultSettings 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)
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 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))
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(Object.keys(initial), o => dispatch(o as Partial<T>))
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)
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)

32
src/common/yt/auth.ts Normal file
View file

@ -0,0 +1,32 @@
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

@ -37,8 +37,7 @@ async function clearExpired() {
})
}
async function clearAll()
{
async function clearAll() {
return await new Promise<void>((resolve, reject) => {
const store = db?.transaction("store", "readwrite").objectStore("store")
if (!store) return resolve()

View file

@ -1,105 +1,81 @@
import { chunk, groupBy } from "lodash"
import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YtUrlResolveResponsePath, ytUrlResolversSettings } from "../settings"
import { chunk } from "lodash"
import { sign } from "../crypto"
import { getExtensionSettingsAsync, ytUrlResolversSettings } from "../settings"
import { LbryPathnameCache } from "./urlCache"
// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS
const QUERY_CHUNK_SIZE = 100
export interface YtIdResolverDescriptor {
id: string
type: 'channel' | 'video'
export type YtUrlResolveItem = { type: 'video' | 'channel', id: string }
type Results = Record<string, YtUrlResolveItem>
type Paramaters = YtUrlResolveItem[]
interface ApiResponse {
channels?: Record<string, string>
videos?: Record<string, string>
}
/**
* @param descriptorsWithIndex YT resource IDs to check
* @returns a promise with the list of channels that were found on lbry
*/
export async function resolveById(descriptors: YtIdResolverDescriptor[], progressCallback?: (progress: number) => void): Promise<(string | null)[]> {
let descriptorsPayload: (YtIdResolverDescriptor & { index: number })[] = descriptors.map((descriptor, index) => ({ ...descriptor, index }))
descriptors = null as any
const results: (string | null)[] = []
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 = {}
descriptorsPayload = (await Promise.all(descriptorsPayload.map(async (descriptor, index) => {
if (!descriptor?.id) return
const cache = await LbryPathnameCache.get(descriptor.id)
// 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 LbryPathnameCache.get(item.id)
// Cache can be null, if there is no lbry url yet
if (cache !== undefined) {
// Null values shouldn't be in the results
if (cache) results[index] = cache
return
}
return descriptor
}))).filter((descriptor) => descriptor) as any
const descriptorsPayloadChunks = chunk(descriptorsPayload, QUERY_CHUNK_SIZE)
let progressCount = 0
await Promise.all(descriptorsPayloadChunks.map(async (descriptorChunk) => {
const descriptorsGroupedByType: Record<YtIdResolverDescriptor['type'], typeof descriptorsPayload | null> = groupBy(descriptorChunk, (descriptor) => descriptor.type) as any
const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync()
const urlResolverSetting = ytUrlResolversSettings[urlResolverSettingName]
const url = new URL(`https://${urlResolverSetting.hostname}`)
function followResponsePath<T>(response: any, responsePath: YtUrlResolveResponsePath) {
for (const path of responsePath) {
switch (typeof path) {
case 'string': case 'number': response = response[path]; continue
}
switch (path) {
case Keys: response = Object.keys(response); continue
case Values: response = Object.values(response); continue
}
// 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
}
return response as T
}
async function requestGroup(urlResolverFunction: YtUrlResolveFunction, descriptorsGroup: typeof descriptorsPayload) {
url.pathname = urlResolverFunction.pathname
Object.entries(urlResolverFunction.defaultParams).forEach(([name, value]) => url.searchParams.set(name, value.toString()))
// No cache found
return item
}))).filter((o) => o) as Paramaters
console.log(params)
if (urlResolverFunction.paramArraySeperator === SingleValueAtATime) {
await Promise.all(descriptorsGroup.map(async (descriptor) => {
url.searchParams.set(urlResolverFunction.valueParamName, descriptor.id)
if (params.length === 0) return results
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
if (apiResponse.ok) {
const value = followResponsePath<string>(await apiResponse.json(), urlResolverFunction.responsePath)
if (value) results[descriptor.index] = value
await LbryPathnameCache.put(value, descriptor.id)
}
else if (apiResponse.status === 404) await LbryPathnameCache.put(null, descriptor.id)
const url = new URL(`${urlResolverSetting.href}`)
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
}))
progressCount++
if (progressCallback) progressCallback(progressCount / descriptorsPayload.length)
}))
}
else {
url.searchParams.set(urlResolverFunction.valueParamName, descriptorsGroup
.map((descriptor) => descriptor.id)
.join(urlResolverFunction.paramArraySeperator))
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] ?? null
// we cache it no matter if its null or not
await LbryPathnameCache.put(lbryUrl, item.id)
const apiResponse = await fetch(url.toString(), { cache: 'no-store' })
if (apiResponse.ok) {
const values = followResponsePath<string[]>(await apiResponse.json(), urlResolverFunction.responsePath)
await Promise.all(values.map(async (value, index) => {
const descriptor = descriptorsGroup[index]
if (value) results[descriptor.index] = value
await LbryPathnameCache.put(value, descriptor.id)
}))
}
progressCount += descriptorsGroup.length
if (progressCallback) progressCallback(progressCount / descriptorsPayload.length)
if (lbryUrl) results[item.id] = { id: lbryUrl, type: item.type }
}
}
if (descriptorsGroupedByType['channel']) await requestGroup(urlResolverSetting.functions.getChannelId, descriptorsGroupedByType['channel'])
if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video'])
}))
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))
}
console.log(results)
if (progressCallback) progressCallback(1)
return results
}

View file

@ -11,7 +11,7 @@
"https://lbry.tv/",
"https://odysee.com/",
"https://madiator.com/",
"https://scrap.madiator.com/",
"https://finder.madiator.com/",
"tabs",
"storage"
],
@ -34,7 +34,7 @@
"scripts/storageSetup.js",
"scripts/background.js"
],
"persistent": false
"persistent": true
},
"browser_action": {
"default_title": "Watch on LBRY",
@ -53,4 +53,4 @@
"128": "icons/wol/icon128.png"
},
"manifest_version": 2
}
}

150
src/popup/popup.css Normal file
View file

@ -0,0 +1,150 @@
: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;
}
:root {
letter-spacing: .2ch;
}
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: absolute;
inset: 0;
background: rgba(19, 19, 19, 0.75);
}
*,
*::before,
*::after {
box-sizing: border-box;
position: relative;
}
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;
padding: .75em;
position: sticky;
top: 0;
background: rgba(19, 19, 19, 0.5);
}
main {
display: grid;
gap: 2em;
padding: 1.5em 0.5em;
}
section {
display: grid;
justify-items: center;
gap: .75em;
}
#popup {
width: 35em;
max-width: 100%;
overflow: hidden;
}
.button {
display: inline-flex;
place-items: center;
text-align: center;
padding: .5em 1em;
background: var(--color-dark);
color: var(--color-light);
cursor: pointer;
border-radius: .5em;
}
.filled {
background: var(--color-gradient-0);
background-clip: text;
-webkit-background-clip: text;
font-weight: bold;
color: transparent;
}
.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;
}
.go-back {
color: currentColor;
text-decoration: none;
font-size: 1.5em;
}
.options {
display: grid;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(3em, 1fr));
justify-content: center;
gap: .25em;
padding: 0 1.5em;
}
.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

@ -6,9 +6,7 @@
</head>
<body>
<div class="container">
<div id="root" />
</div>
<div id="root" />
</body>
</html>

View file

@ -1,16 +0,0 @@
.radio-label
font-size: 1.1rem
display: block
.container
display: grid
grid-auto-flow: row
gap: 1.5em
.container > section
display: grid
grid-auto-flow: row
gap: 1em
button
cursor: pointer

View file

@ -1,56 +1,171 @@
import { h, render } from 'preact'
import { useState } from 'preact/hooks'
import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio'
import { ExtensionSettings, getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings'
import { useLbrySettings } from '../common/useSettings'
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.sass'
import './popup.css'
/** Utilty to set a setting in the browser */
const setSetting = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value })
/** Gets all the options for redirect destinations as selection options */
const platformOptions: SelectionOption[] = getTargetPlatfromSettingsEntiries()
.map(([value, { displayName: display }]) => ({ value, display }))
const targetPlatforms = getTargetPlatfromSettingsEntiries()
const ytUrlResolverOptions = getYtUrlResolversSettingsEntiries()
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
.map(([value, { name: display }]) => ({ value, display }))
function WatchOnLbryPopup(params: { profile: Awaited<ReturnType<typeof getProfile>> | null }) {
const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings()
let [loading, updateLoading] = useState(() => false)
let [popupRoute, updateRoute] = useState<string | null>(() => null)
function WatchOnLbryPopup() {
const { redirect, targetPlatform, urlResolver } = useLbrySettings()
let [clearingCache, updateClearingCache] = useState(() => false)
const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...'
return <div className='container'>
<section>
<label className='radio-label'>Enable Redirection:</label>
<ButtonRadio value={redirect ? 'YES' : 'NO'} options={['YES', 'NO']}
onChange={redirect => setSetting('redirect', redirect.toLowerCase() === 'yes')} />
</section>
<section>
<label className='radio-label'>Where would you like to redirect?</label>
<ButtonRadio value={targetPlatform} options={platformOptions}
onChange={(platform: TargetPlatformName) => setSetting('targetPlatform', platform)} />
</section>
<section>
<label className='radio-label'>Resolve URL with:</label>
<ButtonRadio value={urlResolver} options={ytUrlResolverOptions}
onChange={(urlResolver: YTUrlResolverName) => setSetting('urlResolver', urlResolver)} />
</section>
<section>
<a onClick={async () => {
await LbryPathnameCache.clearAll()
alert('Cleared Cache.')
}}>
<button type='button' className='btn1 button is-primary'>{clearingCache ? 'Clearing Cache...' : 'Clear Cache'}</button>
</a>
</section>
<section>
<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>
</section>
async function startAsyncOperation<T>(operation: Promise<T>) {
try {
updateLoading(true)
await operation
} catch (error) {
console.error(error)
}
finally {
updateLoading(false)
}
}
return <div id='popup'>
{
publicKey
? <header>
<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>
</section>
<section>
{
popupRoute === 'profile'
? <a onClick={() => updateRoute('')} className="button filled"> Back</a>
: <a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Profile Settings</a>
}
</section>
</header>
: <header><a className='button filled' onClick={() => updateRoute('profile')} href="#profile">Your Profile</a>
</header>
}
{
popupRoute === 'profile' ?
publicKey ?
<main>
<section>
<div className='options'>
<a onClick={() => startAsyncOperation(generateProfileAndSetNickname()).then(() => renderPopup())} className={`button active`}>
Change Nickname
</a>
<a onClick={() => 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.") && 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={() => 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.") && startAsyncOperation(importProfileKeysFromFile()).then(() => renderPopup())} 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'>
<a className="button filled">(°° </a>
<a onClick={() => startAsyncOperation(purgeProfile()).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={() => 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.") && startAsyncOperation(generateProfileAndSetNickname(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={() => startAsyncOperation(importProfileKeysFromFile()).then(() => renderPopup())} className={`button`}>
Import
</a>
<a onClick={() => startAsyncOperation(generateProfileAndSetNickname()).then(() => renderPopup())} className={`button active`}>
Generate
</a>
</div>
</section>
</main>
:
<main>
<section>
<label>Pick a mode:</label>
<div className='options'>
<a onClick={() => setSetting('redirect', true)} className={`button ${redirect ? 'active' : ''}`}>
Redirect
</a>
<a onClick={() => setSetting('redirect', false)} className={`button ${redirect ? '' : 'active'}`}>
Show a button
</a>
</div>
</section>
<section>
<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' : ''}`}>
{value.displayName}
</a>
)}
</div>
</section>
<section>
<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' : ''}`}>
{value.name}
</a>
)}
</div>
<a onClick={() => startAsyncOperation(LbryPathnameCache.clearAll()).then(() => alert("Cleared Cache!"))} className={`button active`}>
Clear Resolver Cache
</a>
</section>
<section>
<label>Tools</label>
<a target='_blank' href='/tools/YTtoLBRY.html' className={`button filled`}>
Subscription Converter
</a>
</section>
</main>
}
{loading && <div class="overlay">
<span>Loading...</span>
</div>}
</div>
}
render(<WatchOnLbryPopup />, document.getElementById('root')!)
function renderPopup() {
render(<WatchOnLbryPopup profile={null} />, document.getElementById('root')!)
getProfile().then((profile) => render(<WatchOnLbryPopup profile={profile} />, document.getElementById('root')!))
}
renderPopup()

View file

@ -1,7 +1,7 @@
import { resolveById, YtIdResolverDescriptor } from '../common/yt/urlResolve'
import { resolveById, YtUrlResolveItem } from '../common/yt/urlResolve'
async function resolveYT(descriptor: YtIdResolverDescriptor) {
const lbryProtocolUrl: string | null = (await resolveById([descriptor]).then(a => a[0])) ?? null
async function resolveYT(item: YtUrlResolveItem) {
const lbryProtocolUrl: string | null = (await resolveById([item]).then((items) => items[item.id]))?.id ?? null
if (!lbryProtocolUrl) return null
return lbryProtocolUrl.replaceAll('#', ':')
/* const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true })
@ -25,8 +25,7 @@ async function lbryPathnameFromVideoId(videoId: string): Promise<string | null>
}
chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => {
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) =>
{
lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)).catch((err) => {
sendResponse('error')
console.error(err)
})

View file

@ -6,7 +6,7 @@ async function initSettings() {
// 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] === null || settings[k] === undefined)
.filter(([k]) => settings[k] === undefined || settings[k] === null)
// fix our local var and set it in storage for later
if (invalidEntries.length > 0) {

View file

@ -117,32 +117,30 @@ async function requestLbryPathname(videoId: string) {
async function redirectTo({ lbryPathname, platfrom, time }: Target) {
const url = new URL(`${platfrom.domainPrefix}${lbryPathname}`)
if (time) url.searchParams.set('t', time.toFixed(0))
findVideoElement().then((videoElement) => {
videoElement.addEventListener('play', () => videoElement.pause(), { once: true })
videoElement.pause()
})
if (platfrom === targetPlatformSettings.app) {
if (document.hidden) await new Promise((resolve) => document.addEventListener('visibilitychange', resolve, { once: true }))
// On redirect with app, people might choose to cancel browser's dialog
// So we dont destroy the current window automatically for them
// And also we are keeping the same window for less distiraction
if (settings.redirect)
{
if (settings.redirect) {
location.replace(url.toString())
}
else
{
else {
open(url.toString(), '_blank')
if (window.history.length === 1) window.close()
else window.history.back()
}
}
else
else
location.replace(url.toString())
}

View file

@ -18,12 +18,14 @@ async function lbryChannelsFromFile(file: File) {
ext === 'xml' || ext == 'opml' ? getSubsFromOpml :
ext === 'csv' ? getSubsFromCsv :
getSubsFromJson)(await getFileContent(file)))
const lbryPathnames = await resolveById(
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 lbryPathnames.map(channel => urlPrefix + channel)
return Object.values(items).map((item) => urlPrefix + item.id)
}
function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<void> | void, progress: number }) {