diff --git a/src/common/common.css b/src/common/common.css new file mode 100644 index 0000000..294b5f5 --- /dev/null +++ b/src/common/common.css @@ -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; +} \ No newline at end of file diff --git a/src/common/crypto.ts b/src/common/crypto.ts new file mode 100644 index 0000000..c5b5d39 --- /dev/null +++ b/src/common/crypto.ts @@ -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((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) + } +} \ No newline at end of file diff --git a/src/common/settings.ts b/src/common/settings.ts index c6fa00c..2e5e991 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -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 { 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 +export type TargetPlatformName = Extract +export const getTargetPlatfromSettingsEntiries = () => { + return Object.entries(targetPlatformSettings) as any as [Extract, TargetPlatform][] } - -export const targetPlatformSettings: Record = { - '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 icon: { transform: 'scale(1.2)' } } } - }, - odysee: { + }), + odysee: targetPlatform({ domainPrefix: 'https://odysee.com/', displayName: 'Odysee', theme: '#1e013b', @@ -51,8 +61,8 @@ export const targetPlatformSettings: Record 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 text: 'Watch on LBRY', icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') } - }, -} - -export const getTargetPlatfromSettingsEntiries = () => { - return Object.entries(targetPlatformSettings) as any as [Extract, 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 = { - "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 +export type SourcePlatformName = Extract 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 - 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 = { - lbryInc: { + href: string + signRequest: boolean +}) => o +export type YTUrlResolver = ReturnType +export type YTUrlResolverName = Extract +export const getYtUrlResolversSettingsEntiries = () => Object.entries(ytUrlResolversSettings) as any as [Extract, 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, 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 + }) } \ No newline at end of file diff --git a/src/common/useSettings.ts b/src/common/useSettings.ts index 26e519d..5d7b8f1 100644 --- a/src/common/useSettings.ts +++ b/src/common/useSettings.ts @@ -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(initial: T) { - const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), initial) +function useSettings(defaultSettings: ExtensionSettings) { + const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...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, 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)) + 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 = (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) \ No newline at end of file diff --git a/src/common/yt/auth.ts b/src/common/yt/auth.ts new file mode 100644 index 0000000..32ea04f --- /dev/null +++ b/src/common/yt/auth.ts @@ -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, + }) +} \ No newline at end of file diff --git a/src/common/yt/urlCache.ts b/src/common/yt/urlCache.ts index 8adcc63..9d2535d 100644 --- a/src/common/yt/urlCache.ts +++ b/src/common/yt/urlCache.ts @@ -37,8 +37,7 @@ async function clearExpired() { }) } -async function clearAll() -{ +async function clearAll() { return await new Promise((resolve, reject) => { const store = db?.transaction("store", "readwrite").objectStore("store") if (!store) return resolve() diff --git a/src/common/yt/urlResolve.ts b/src/common/yt/urlResolve.ts index d02e3d4..c83835f 100644 --- a/src/common/yt/urlResolve.ts +++ b/src/common/yt/urlResolve.ts @@ -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 +type Paramaters = YtUrlResolveItem[] + +interface ApiResponse { + channels?: Record + videos?: Record } -/** -* @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 { + 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 = 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(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(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(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 } diff --git a/src/manifest.json b/src/manifest.json index 891ccf8..e4ca076 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -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 -} +} \ No newline at end of file diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..a26fbe9 --- /dev/null +++ b/src/popup/popup.css @@ -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; +} \ No newline at end of file diff --git a/src/popup/popup.html b/src/popup/popup.html index d419cc8..1312995 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -6,9 +6,7 @@ -
-
-
+
\ No newline at end of file diff --git a/src/popup/popup.sass b/src/popup/popup.sass deleted file mode 100644 index ed3c5bd..0000000 --- a/src/popup/popup.sass +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index b9e506d..ebe8f92 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -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 = (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> | null }) { + const { redirect, targetPlatform, urlResolver, privateKey, publicKey } = useLbrySettings() + let [loading, updateLoading] = useState(() => false) + let [popupRoute, updateRoute] = useState(() => null) -function WatchOnLbryPopup() { - const { redirect, targetPlatform, urlResolver } = useLbrySettings() - let [clearingCache, updateClearingCache] = useState(() => false) + const nickname = params.profile ? params.profile.nickname ?? 'No Nickname' : '...' - return
-
- - setSetting('redirect', redirect.toLowerCase() === 'yes')} /> -
-
- - setSetting('targetPlatform', platform)} /> -
-
- - setSetting('urlResolver', urlResolver)} /> -
-
- { - await LbryPathnameCache.clearAll() - alert('Cleared Cache.') - }}> - - -
-
- - - - -
+ async function startAsyncOperation(operation: Promise) { + try { + updateLoading(true) + await operation + } catch (error) { + console.error(error) + } + finally { + updateLoading(false) + } + } + + return } -render(, document.getElementById('root')!) +function renderPopup() { + render(, document.getElementById('root')!) + getProfile().then((profile) => render(, document.getElementById('root')!)) +} + +renderPopup() \ No newline at end of file diff --git a/src/scripts/background.ts b/src/scripts/background.ts index c30af4f..233f30c 100644 --- a/src/scripts/background.ts +++ b/src/scripts/background.ts @@ -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 } 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) }) diff --git a/src/scripts/storageSetup.ts b/src/scripts/storageSetup.ts index ccfbcac..e79a159 100644 --- a/src/scripts/storageSetup.ts +++ b/src/scripts/storageSetup.ts @@ -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) { diff --git a/src/scripts/ytContent.tsx b/src/scripts/ytContent.tsx index 5a0de20..1d47618 100644 --- a/src/scripts/ytContent.tsx +++ b/src/scripts/ytContent.tsx @@ -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()) } diff --git a/src/tools/YTtoLBRY.tsx b/src/tools/YTtoLBRY.tsx index 5faaa29..8709429 100644 --- a/src/tools/YTtoLBRY.tsx +++ b/src/tools/YTtoLBRY.tsx @@ -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(, 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, progress: number }) {