diff --git a/src/common/components/ButtonRadio.tsx b/src/common/components/ButtonRadio.tsx index c8aa9c7..b38ce41 100644 --- a/src/common/components/ButtonRadio.tsx +++ b/src/common/components/ButtonRadio.tsx @@ -1,7 +1,7 @@ -import { h } from 'preact'; -import classnames from 'classnames'; +import classnames from 'classnames' +import { h } from 'preact' +import './ButtonRadio.sass' -import './ButtonRadio.sass'; export interface SelectionOption { value: string @@ -9,13 +9,13 @@ export interface SelectionOption { } export interface ButtonRadioProps { - name?: string; - onChange(redirect: string): void; - value: T extends SelectionOption ? T['value'] : T; - options: T[]; + 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]; +const getAttr = (x: string | SelectionOption, key: keyof SelectionOption): string => typeof x === 'string' ? x : x[key] export default function ButtonRadio({ name = 'buttonRadio', onChange, options, value }: ButtonRadioProps) { /** If it's a string, return the string, if it's a SelectionOption get the selection option property */ @@ -27,5 +27,5 @@ export default function ButtonRadio )} - ; + } diff --git a/src/common/lbry-url.spec.ts b/src/common/lbry-url.spec.ts index 3c492b1..14d30f0 100644 --- a/src/common/lbry-url.spec.ts +++ b/src/common/lbry-url.spec.ts @@ -1,4 +1,4 @@ -import { appRedirectUrl, parseProtocolUrl } from './lbry-url'; +import { appRedirectUrl, parseProtocolUrl } from './lbry-url' describe('web url parsing', () => { const testCases: [string, string | undefined][] = [ @@ -9,21 +9,21 @@ describe('web url parsing', () => { ['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); - }); -}); + 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); - }); -}); + expect(parseProtocolUrl(url)).toEqual(expected) + }) +}) diff --git a/src/common/lbry-url.ts b/src/common/lbry-url.ts index 0eede34..20873b4 100644 --- a/src/common/lbry-url.ts +++ b/src/common/lbry-url.ts @@ -8,14 +8,14 @@ interface UrlOptions { encode?: boolean } -const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source; +const invalidNamesRegex = /[^=&#:$@%*?;\"/\\<>%{}|^~`\[\]\u0000-\u0020\uD800-\uDFFF\uFFFE-\uFFFF]+/.source /** Creates a named regex group */ -const named = (name: string, regex: string) => `(?<${name}>${regex})`; +const named = (name: string, regex: string) => `(?<${name}>${regex})` /** Creates a non-capturing group */ -const group = (regex: string) => `(?:${regex})`; +const group = (regex: string) => `(?:${regex})` /** Allows for one of the patterns */ -const oneOf = (...choices: string[]) => group(choices.join('|')); +const oneOf = (...choices: string[]) => group(choices.join('|')) /** Create an lbry url claim */ const claim = (name: string, prefix = '') => group( named(`${name}_name`, prefix + invalidNamesRegex) @@ -24,7 +24,7 @@ const claim = (name: string, prefix = '') => group( 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( @@ -33,34 +33,34 @@ const legacyClaim = (name: string, prefix = '') => group( 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 }; +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; + 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; + 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); +export const URL_REGEX = createWebUrlRegex() +export const PROTOCOL_URL_REGEX = createProtocolUrlRegex() +const PROTOCOL_URL_REGEX_LEGACY = createProtocolUrlRegex(true) /** * Encapsulates a lbry url path segment. @@ -78,15 +78,15 @@ export class PathSegment { 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; + 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 } } @@ -98,18 +98,18 @@ export class PathSegment { * @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 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; + : match['stream_name'] ? ['stream'] + : null - if (!segments) throw new Error(`${url} matched the overall pattern, but could not determine type`); + 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); + .map(s => options.encode ? encodeURIComponent(s) : s) } /** @@ -119,11 +119,11 @@ function patternSegmenter(ptn: RegExp, url: string, options: UrlOptions = { enco * @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('/'); + const segments = patternSegmenter(URL_REGEX, new URL(url).pathname, options) + if (segments.length === 0) return + const path = segments.join('/') - return `lbry://${path}`; + return `lbry://${path}` } /** @@ -134,9 +134,9 @@ export function appRedirectUrl(url: string, options?: UrlOptions): string | unde */ 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; + const segments = patternSegmenter(ptn, url, options) + if (segments.length === 0) continue + return segments } - return []; + return [] } diff --git a/src/common/settings.ts b/src/common/settings.ts index 2200048..9a37163 100644 --- a/src/common/settings.ts +++ b/src/common/settings.ts @@ -1,50 +1,76 @@ +import { JSX } from "preact" + export interface ExtensionSettings { redirect: boolean targetPlatform: TargetPlatformName urlResolver: YTUrlResolverName } -export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' }; +export const DEFAULT_SETTINGS: ExtensionSettings = { redirect: true, targetPlatform: 'odysee', urlResolver: 'lbryInc' } -export function getExtensionSettingsAsync>(...keys: K): Promise> { - return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any))); +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 TargetPlatformSettings { +export type TargetPlatformName = 'madiator.com' | 'odysee' | 'app' +export interface TargetPlatform { domainPrefix: string displayName: string theme: string + button: { + text: string + icon: string + style?: + { + icon?: JSX.CSSProperties + button?: JSX.CSSProperties + } + } } -export const targetPlatformSettings: Record = { - 'madiator.com': { - domainPrefix: 'https://madiator.com/', - displayName: 'Madiator.com', - theme: '#075656' +export const targetPlatformSettings: Record = { + 'madiator.com': { + domainPrefix: 'https://madiator.com/', + displayName: 'Madiator.com', + theme: '#075656', + button: { + text: 'Watch on', + icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'), + style: { + button: { flexDirection: 'row-reverse' }, + icon: { transform: 'scale(1.2)' } + } + } }, - odysee: { - domainPrefix: 'https://odysee.com/', - displayName: 'Odysee', - theme: '#1e013b' + odysee: { + domainPrefix: 'https://odysee.com/', + displayName: 'Odysee', + theme: '#1e013b', + button: { + text: 'Watch on Odysee', + icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg') + } }, - app: { - domainPrefix: 'lbry://', - displayName: 'LBRY App', - theme: '#075656' + app: { + domainPrefix: 'lbry://', + displayName: 'LBRY App', + theme: '#075656', + button: { + text: 'Watch on LBRY', + icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') + } }, -}; +} export const getTargetPlatfromSettingsEntiries = () => { - return Object.entries(targetPlatformSettings) as any as [Extract, TargetPlatformSettings][] + return Object.entries(targetPlatformSettings) as any as [Extract, TargetPlatform][] } -export type SourcePlatfromName = 'youtube.com' | 'yewtu.be' -export interface SourcePlatfromSettings { +export type SourcePlatformName = 'youtube.com' | 'yewtu.be' +export interface SourcePlatform { hostnames: string[] htmlQueries: { mountButtonBefore: string, @@ -52,7 +78,7 @@ export interface SourcePlatfromSettings { } } -export const sourcePlatfromSettings: Record = { +export const sourcePlatfromSettings: Record = { "yewtu.be": { hostnames: ['yewtu.be'], htmlQueries: { @@ -77,21 +103,19 @@ export function getSourcePlatfromSettingsFromHostname(hostname: string) { } -export type YTUrlResolverName = 'lbryInc' | 'madiatorScrap' +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 -{ +export interface YtUrlResolveFunction { pathname: string paramName: string paramArraySeperator: string | typeof SingleValueAtATime responsePath: YtUrlResolveResponsePath } -export interface YTUrlResolver -{ +export interface YTUrlResolver { name: string hostname: string functions: { @@ -102,10 +126,10 @@ export interface YTUrlResolver export const ytUrlResolversSettings: Record = { lbryInc: { - name: "LBRY Inc.", + name: "Odysee", hostname: "api.odysee.com", functions: { - getChannelId : { + getChannelId: { pathname: "/yt/resolve", paramName: "channel_ids", paramArraySeperator: ',', diff --git a/src/common/useSettings.ts b/src/common/useSettings.ts index b1d9113..26e519d 100644 --- a/src/common/useSettings.ts +++ b/src/common/useSettings.ts @@ -1,5 +1,5 @@ -import { useReducer, useEffect } from 'preact/hooks'; -import { DEFAULT_SETTINGS } from './settings'; +import { useEffect, useReducer } from 'preact/hooks' +import { DEFAULT_SETTINGS } from './settings' /** * A hook to read the settings from local storage @@ -7,24 +7,24 @@ import { DEFAULT_SETTINGS } from './settings'; * @param initial 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); - // 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)); - }; - chrome.storage.onChanged.addListener(changeListener); - chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial)); - return () => chrome.storage.onChanged.removeListener(changeListener); - }, []); + const [state, dispatch] = useReducer((state, nstate: Partial) => ({ ...state, ...nstate }), initial) + // 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)) + } + chrome.storage.onChanged.addListener(changeListener) + chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial)) + return () => chrome.storage.onChanged.removeListener(changeListener) + }, []) - return state; - } + return state +} - /** A hook to read watch on lbry settings from local storage */ - export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS); +/** A hook to read watch on lbry settings from local storage */ +export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS) diff --git a/src/common/yt.ts b/src/common/yt.ts deleted file mode 100644 index 6d255e2..0000000 --- a/src/common/yt.ts +++ /dev/null @@ -1,292 +0,0 @@ -import chunk from 'lodash/chunk'; -import groupBy from 'lodash/groupBy'; -import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YTUrlResolver, YtUrlResolveResponsePath, ytUrlResolversSettings } from './settings' - -// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS -const QUERY_CHUNK_SIZE = 300; - -interface YtExportedJsonSubscription { - id: string; - etag: string; - title: string; - snippet: { - description: string; - resourceId: { - channelId: string; - }; - }; -} - -/** - * @param file to load - * @returns a promise with the file as a string - */ -export function getFileContent(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener('load', event => resolve(event.target?.result as string || '')); - reader.addEventListener('error', () => { - reader.abort(); - reject(new DOMException(`Could not read ${file.name}`)); - }); - reader.readAsText(file); - }); -} - -export interface YtIdResolverDescriptor { - id: string - type: 'channel' | 'video' -} - -const URLResolverCache = (() => -{ - const openRequest = indexedDB.open("yt-url-resolver-cache") - - if (typeof self.indexedDB !== 'undefined') - { - openRequest.addEventListener('upgradeneeded', () => - { - const db = openRequest.result - const store = db.createObjectStore("store") - store.createIndex("expireAt", "expireAt") - }) - - // Delete Expired - openRequest.addEventListener('success', () => - { - const db = openRequest.result - const transaction = db.transaction("store", "readwrite") - const range = IDBKeyRange.upperBound(new Date()) - - const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range) - expireAtCursorRequest.addEventListener('success', () => - { - const expireCursor = expireAtCursorRequest.result - if (!expireCursor) return - expireCursor.delete() - expireCursor.continue() - }) - }) - } - else console.warn(`IndexedDB not supported`) - - async function put(url: string | null, id: string) : Promise - { - return await new Promise((resolve, reject) => - { - const db = openRequest.result - if (!db) return resolve() - const store = db.transaction("store", "readwrite").objectStore("store") - const putRequest = store.put({ value: url, expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, id) - putRequest.addEventListener('success', () => resolve()) - putRequest.addEventListener('error', () => reject(putRequest.error)) - }) - } - async function get(id: string): Promise - { - return (await new Promise((resolve, reject) => - { - const db = openRequest.result - if (!db) return resolve(null) - const store = db.transaction("store", "readonly").objectStore("store") - const getRequest = store.get(id) - getRequest.addEventListener('success', () => resolve(getRequest.result)) - getRequest.addEventListener('error', () => reject(getRequest.error)) - }) as any)?.value - } - - return { put, get } -})() - -export const ytService = { - - /** - * Reads the array of YT channels from an OPML file - * - * @param opmlContents an opml file as as tring - * @returns the channel IDs - */ - readOpml(opmlContents: string): string[] { - const opml = new DOMParser().parseFromString(opmlContents, 'application/xml'); - opmlContents = '' - return Array.from(opml.querySelectorAll('outline > outline')) - .map(outline => outline.getAttribute('xmlUrl')) - .filter((url): url is string => !!url) - .map(url => ytService.getChannelId(url)) - .filter((url): url is string => !!url); // we don't want it if it's empty - }, - - /** - * Reads an array of YT channel IDs from the YT subscriptions JSON file - * - * @param jsonContents a JSON file as a string - * @returns the channel IDs - */ - readJson(jsonContents: string): string[] { - const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents); - jsonContents = '' - return subscriptions.map(sub => sub.snippet.resourceId.channelId); - }, - - /** - * Reads an array of YT channel IDs from the YT subscriptions CSV file - * - * @param csvContent a CSV file as a string - * @returns the channel IDs - */ - readCsv(csvContent: string): string[] { - const rows = csvContent.split('\n') - csvContent = '' - return rows.slice(1).map((row) => row.substring(0, row.indexOf(','))) - }, - - /** - * Extracts the channelID from a YT URL. - * - * Handles these two types of YT URLs: - * * /feeds/videos.xml?channel_id=* - * * /channel/* - */ - getChannelId(channelURL: string) { - const match = channelURL.match(/channel\/([^\s?]*)/); - return match ? match[1] : new URL(channelURL).searchParams.get('channel_id'); - }, - - /** Extracts the video ID from a YT URL */ - getVideoId(url: string) { - const regex = /watch\/?\?.*v=([^\s&]*)/; - const match = url.match(regex); - return match ? match[1] : null; // match[1] is the videoId - }, - - getId(url: string): YtIdResolverDescriptor | null { - const videoId = ytService.getVideoId(url); - if (videoId) return { id: videoId, type: 'video' }; - const channelId = ytService.getChannelId(url); - if (channelId) return { id: channelId, type: 'channel' }; - return null; - }, - - /** - * @param descriptorsWithIndex YT resource IDs to check - * @returns a promise with the list of channels that were found on lbry - */ - async resolveById(descriptors: YtIdResolverDescriptor[], progressCallback?: (progress: number) => void): Promise<(string | null)[]> { - const descriptorsWithIndex: (YtIdResolverDescriptor & { index: number })[] = descriptors.map((descriptor, index) => ({...descriptor, index})) - descriptors = null as any - const results: (string | null)[] = [] - - await Promise.all(descriptorsWithIndex.map(async (descriptor, index) => { - if (!descriptor) return - const cache = await URLResolverCache.get(descriptor.id) - - // Cache can be null, if there is no lbry url yet - if (cache !== undefined) { - // Directly setting it to results - results[index] = cache - - // We remove it so we dont ask it to API - descriptorsWithIndex.splice(index, 1) - } - })) - - const descriptorsChunks = chunk(descriptorsWithIndex, QUERY_CHUNK_SIZE); - let progressCount = 0; - await Promise.all(descriptorsChunks.map(async (descriptorChunk) => - { - const descriptorsGroupedByType: Record = groupBy(descriptorChunk, (descriptor) => descriptor.type) as any; - - const { urlResolver: urlResolverSettingName } = await getExtensionSettingsAsync('urlResolver') - 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] - break - default: - switch (path) - { - case Keys: - response = Object.keys(response) - break - case Values: - response = Object.values(response) - break - } - } - } - return response as T - } - - async function requestGroup(urlResolverFunction: YtUrlResolveFunction, descriptorsGroup: typeof descriptorsWithIndex) - { - url.pathname = urlResolverFunction.pathname - - if (urlResolverFunction.paramArraySeperator === SingleValueAtATime) - { - await Promise.all(descriptorsGroup.map(async (descriptor) => { - switch (null) - { - default: - if (!descriptor.id) break - url.searchParams.set(urlResolverFunction.paramName, descriptor.id) - - const apiResponse = await fetch(url.toString(), { cache: 'no-store' }); - if (!apiResponse.ok) { - // Some API might not respond with 200 if it can't find the url - if (apiResponse.status === 404) await URLResolverCache.put(null, descriptor.id) - break - } - - const value = followResponsePath(await apiResponse.json(), urlResolverFunction.responsePath) - if (value) results[descriptor.index] = value - await URLResolverCache.put(value, descriptor.id) - } - progressCount++ - if (progressCallback) progressCallback(progressCount / descriptorsWithIndex.length) - })) - } - else - { - - switch (null) - { - default: - url.searchParams - .set(urlResolverFunction.paramName, descriptorsGroup - .map((descriptor) => descriptor.id) - .filter((descriptorId) => descriptorId) - .join(urlResolverFunction.paramArraySeperator) - ) - - const apiResponse = await fetch(url.toString(), { cache: 'no-store' }); - if (!apiResponse.ok) break - 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 URLResolverCache.put(value, descriptor.id) - })) - } - progressCount += descriptorsGroup.length - if (progressCallback) progressCallback(progressCount / descriptorsWithIndex.length) - } - } - - if (descriptorsGroupedByType['channel']) await requestGroup(urlResolverSetting.functions.getChannelId, descriptorsGroupedByType['channel']) - if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video']) - - })); - - return results - } -} diff --git a/src/common/yt/index.ts b/src/common/yt/index.ts new file mode 100644 index 0000000..68184f5 --- /dev/null +++ b/src/common/yt/index.ts @@ -0,0 +1,101 @@ + +interface YtExportedJsonSubscription { + id: string + etag: string + title: string + snippet: { + description: string + resourceId: { + channelId: string + } + } +} + + +/** + * @param file to load + * @returns a promise with the file as a string + */ +export function getFileContent(file: File): Promise { + 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 + * + * @param opmlContents an opml file as as tring + * @returns the channel IDs + */ +export function getSubsFromOpml(opmlContents: string): string[] { + const opml = new DOMParser().parseFromString(opmlContents, 'application/xml') + opmlContents = '' + return Array.from(opml.querySelectorAll('outline > outline')) + .map(outline => outline.getAttribute('xmlUrl')) + .filter((url): url is string => !!url) + .map(url => getChannelId(url)) + .filter((url): url is string => !!url) // we don't want it if it's empty +} + +/** + * Reads an array of YT channel IDs from the YT subscriptions JSON file + * + * @param jsonContents a JSON file as a string + * @returns the channel IDs + */ +export function getSubsFromJson(jsonContents: string): string[] { + const subscriptions: YtExportedJsonSubscription[] = JSON.parse(jsonContents) + jsonContents = '' + return subscriptions.map(sub => sub.snippet.resourceId.channelId) +} + +/** + * Reads an array of YT channel IDs from the YT subscriptions CSV file + * + * @param csvContent a CSV file as a string + * @returns the channel IDs + */ +export function getSubsFromCsv(csvContent: string): string[] { + const rows = csvContent.split('\n') + csvContent = '' + return rows.slice(1).map((row) => row.substring(0, row.indexOf(','))) +} + +/** + * Extracts the channelID from a YT URL. + * + * Handles these two types of YT URLs: + * * /feeds/videos.xml?channel_id=* + * * /channel/* + */ +export function getChannelId(channelURL: string) { + const match = channelURL.match(/channel\/([^\s?]*)/) + return match ? match[1] : new URL(channelURL).searchParams.get('channel_id') +} + +export function parseYouTubeURLTimeString(timeString: string) { + const signs = timeString.replace(/[0-9]/g, '') + if (signs.length === 0) return null + const numbers = timeString.replace(/[^0-9]/g, '-').split('-') + let total = 0 + for (let i = 0; i < signs.length; i++) { + let t = parseInt(numbers[i]) + switch (signs[i]) { + case 'd': t *= 24 + case 'h': t *= 60 + case 'm': t *= 60 + case 's': break + default: return null + } + total += t + } + return total +} \ No newline at end of file diff --git a/src/common/yt/urlCache.ts b/src/common/yt/urlCache.ts new file mode 100644 index 0000000..1a47c8d --- /dev/null +++ b/src/common/yt/urlCache.ts @@ -0,0 +1,48 @@ +// This should only work in background + +let db: IDBDatabase | null = null + +if (typeof self.indexedDB !== 'undefined') { + const openRequest = indexedDB.open("yt-url-resolver-cache") + openRequest.addEventListener('upgradeneeded', () => openRequest.result.createObjectStore("store").createIndex("expireAt", "expireAt")) + + // Delete Expired + openRequest.addEventListener('success', () => { + db = openRequest.result + const transaction = db.transaction("store", "readwrite") + const range = IDBKeyRange.upperBound(new Date()) + + const expireAtCursorRequest = transaction.objectStore("store").index("expireAt").openCursor(range) + expireAtCursorRequest.addEventListener('success', () => { + const expireCursor = expireAtCursorRequest.result + if (!expireCursor) return + expireCursor.delete() + expireCursor.continue() + }) + }) +} +else console.warn(`IndexedDB not supported`) + + +async function put(url: string | null, id: string): Promise { + return await new Promise((resolve, reject) => { + const store = db?.transaction("store", "readwrite").objectStore("store") + if (!store) return resolve() + const request = store.put({ value: url, expireAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, id) + request.addEventListener('success', () => resolve()) + request.addEventListener('error', () => reject(request.error)) + }) +} + +async function get(id: string): Promise { + return (await new Promise((resolve, reject) => { + const store = db?.transaction("store", "readonly").objectStore("store") + if (!store) return resolve(null) + const request = store.get(id) + request.addEventListener('success', () => resolve(request.result)) + request.addEventListener('error', () => reject(request.error)) + }) as any)?.value +} + +export const LbryPathnameCache = { put, get } + diff --git a/src/common/yt/urlResolve.ts b/src/common/yt/urlResolve.ts new file mode 100644 index 0000000..1e6c37e --- /dev/null +++ b/src/common/yt/urlResolve.ts @@ -0,0 +1,104 @@ +import { chunk, groupBy } from "lodash" +import { getExtensionSettingsAsync, Keys, SingleValueAtATime, Values, YtUrlResolveFunction, YtUrlResolveResponsePath, ytUrlResolversSettings } from "../settings" +import { LbryPathnameCache } from "./urlCache" + +// const LBRY_API_HOST = 'https://api.odysee.com'; MOVED TO SETTINGS +const QUERY_CHUNK_SIZE = 300 + +export interface YtIdResolverDescriptor { + id: string + type: 'channel' | 'video' +} + +/** +* @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)[] = [] + + + descriptorsPayload = (await Promise.all(descriptorsPayload.map(async (descriptor, index) => { + if (!descriptor?.id) return + const cache = await LbryPathnameCache.get(descriptor.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 + } + } + return response as T + } + + async function requestGroup(urlResolverFunction: YtUrlResolveFunction, descriptorsGroup: typeof descriptorsPayload) { + url.pathname = urlResolverFunction.pathname + + if (urlResolverFunction.paramArraySeperator === SingleValueAtATime) { + await Promise.all(descriptorsGroup.map(async (descriptor) => { + url.searchParams.set(urlResolverFunction.paramName, descriptor.id) + + 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) + + progressCount++ + if (progressCallback) progressCallback(progressCount / descriptorsPayload.length) + })) + } + else { + url.searchParams.set(urlResolverFunction.paramName, descriptorsGroup + .map((descriptor) => descriptor.id) + .join(urlResolverFunction.paramArraySeperator)) + + 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 (descriptorsGroupedByType['channel']) await requestGroup(urlResolverSetting.functions.getChannelId, descriptorsGroupedByType['channel']) + if (descriptorsGroupedByType['video']) await requestGroup(urlResolverSetting.functions.getVideoId, descriptorsGroupedByType['video']) + })) + if (progressCallback) progressCallback(1) + return results +} \ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts index f7ba0eb..55d74f2 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -1,4 +1,4 @@ declare module '*.md' { - var _: string; - export default _; + var _: string + export default _ } diff --git a/src/manifest.json b/src/manifest.json index feaa684..4dbb9bf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -25,7 +25,7 @@ "background": { "scripts": [ "scripts/storageSetup.js", - "scripts/tabOnUpdated.js" + "scripts/background.js" ], "persistent": false }, @@ -46,4 +46,4 @@ "128": "icons/wol/icon128.png" }, "manifest_version": 2 -} +} \ No newline at end of file diff --git a/src/popup/popup.html b/src/popup/popup.html index 9f432db..d419cc8 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -11,4 +11,4 @@ - + \ No newline at end of file diff --git a/src/popup/popup.tsx b/src/popup/popup.tsx index e901b05..b804a72 100644 --- a/src/popup/popup.tsx +++ b/src/popup/popup.tsx @@ -1,21 +1,21 @@ import { h, render } from 'preact' import ButtonRadio, { SelectionOption } from '../common/components/ButtonRadio' -import { getTargetPlatfromSettingsEntiries, ExtensionSettings, TargetPlatformName, getYtUrlResolversSettingsEntiries, YTUrlResolverName } from '../common/settings' +import { ExtensionSettings, getTargetPlatfromSettingsEntiries, getYtUrlResolversSettingsEntiries, TargetPlatformName, YTUrlResolverName } from '../common/settings' import { useLbrySettings } from '../common/useSettings' import './popup.sass' /** Utilty to set a setting in the browser */ -const setSetting = (setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value }); +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 })); + .map(([value, { displayName: display }]) => ({ value, display })) - const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries() - .map(([value, { name: display }]) => ({ value, display })); +const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries() + .map(([value, { name: display }]) => ({ value, display })) function WatchOnLbryPopup() { - const { redirect, targetPlatform, urlResolver } = useLbrySettings(); + const { redirect, targetPlatform, urlResolver } = useLbrySettings() return
@@ -39,7 +39,7 @@ function WatchOnLbryPopup() {
-
; + } -render(, document.getElementById('root')!); +render(, document.getElementById('root')!) diff --git a/src/scripts/background.ts b/src/scripts/background.ts new file mode 100644 index 0000000..5e5abf4 --- /dev/null +++ b/src/scripts/background.ts @@ -0,0 +1,24 @@ +import { parseProtocolUrl } from '../common/lbry-url' +import { resolveById, YtIdResolverDescriptor } from '../common/yt/urlResolve' +async function resolveYT(descriptor: YtIdResolverDescriptor) { + const lbryProtocolUrl: string | null = await resolveById([descriptor]).then(a => a[0]) + const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true }) + if (segments.length === 0) return + return segments.join('/') +} + +const onGoingLbryPathnameRequest: Record> = {} +async function lbryPathnameFromVideoId(videoId: string): Promise { + // Don't create a new Promise for same ID until on going one is over. + const promise = onGoingLbryPathnameRequest[videoId] ?? (onGoingLbryPathnameRequest[videoId] = resolveYT({ id: videoId, type: 'video' })) + await promise + delete onGoingLbryPathnameRequest[videoId] + return await promise +} + +chrome.runtime.onMessage.addListener(({ videoId }: { videoId: string }, sender, sendResponse) => { + lbryPathnameFromVideoId(videoId).then((lbryPathname) => sendResponse(lbryPathname)) + return true +}) + +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => changeInfo.url && chrome.tabs.sendMessage(tabId, {})) \ No newline at end of file diff --git a/src/scripts/storageSetup.ts b/src/scripts/storageSetup.ts index f574108..ccfbcac 100644 --- a/src/scripts/storageSetup.ts +++ b/src/scripts/storageSetup.ts @@ -1,28 +1,28 @@ -import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync } from '../common/settings'; +import { DEFAULT_SETTINGS, ExtensionSettings, getExtensionSettingsAsync } from '../common/settings' /** Reset settings to default value and update the browser badge text */ async function initSettings() { - const settings = await getExtensionSettingsAsync(...Object.keys(DEFAULT_SETTINGS) as Array); + const settings = await getExtensionSettingsAsync() // get all the values that aren't set and use them as a change set const invalidEntries = (Object.entries(DEFAULT_SETTINGS) as Array<[keyof ExtensionSettings, ExtensionSettings[keyof ExtensionSettings]]>) - .filter(([k]) => settings[k] === null || settings[k] === undefined); + .filter(([k]) => settings[k] === null || settings[k] === undefined) // fix our local var and set it in storage for later if (invalidEntries.length > 0) { - const changeSet = Object.fromEntries(invalidEntries); - Object.assign(settings, changeSet); - chrome.storage.local.set(changeSet); + const changeSet = Object.fromEntries(invalidEntries) + Object.assign(settings, changeSet) + chrome.storage.local.set(changeSet) } - chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' }); + chrome.browserAction.setBadgeText({ text: settings.redirect ? 'ON' : 'OFF' }) } chrome.storage.onChanged.addListener((changes, areaName) => { - if (areaName !== 'local' || !changes.redirect) return; - chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' }); -}); + if (areaName !== 'local' || !changes.redirect) return + chrome.browserAction.setBadgeText({ text: changes.redirect.newValue ? 'ON' : 'OFF' }) +}) -chrome.runtime.onStartup.addListener(initSettings); -chrome.runtime.onInstalled.addListener(initSettings); +chrome.runtime.onStartup.addListener(initSettings) +chrome.runtime.onInstalled.addListener(initSettings) diff --git a/src/scripts/tabOnUpdated.ts b/src/scripts/tabOnUpdated.ts deleted file mode 100644 index cf67bfc..0000000 --- a/src/scripts/tabOnUpdated.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { appRedirectUrl, parseProtocolUrl } from '../common/lbry-url' -import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatformName } from '../common/settings' -import { YtIdResolverDescriptor, ytService } from '../common/yt' -export interface UpdateContext { - descriptor: YtIdResolverDescriptor - /** LBRY URL fragment */ - lbryPathname: string - redirect: boolean - targetPlatform: TargetPlatformName -} - -async function resolveYT(descriptor: YtIdResolverDescriptor) { - const lbryProtocolUrl: string | null = await ytService.resolveById([descriptor]).then(a => a[0]); - const segments = parseProtocolUrl(lbryProtocolUrl || '', { encode: true }); - if (segments.length === 0) return; - return segments.join('/'); -} - -const ctxFromURLOnGoingPromise: Record> = {} -async function ctxFromURL(href: string): Promise { - if (!href) return; - - const url = new URL(href); - if (!getSourcePlatfromSettingsFromHostname(url.hostname)) return - if (!(url.pathname.startsWith('/watch') || url.pathname.startsWith('/channel'))) return - - const descriptor = ytService.getId(href); - if (!descriptor) return; // couldn't get the ID, so we're done - - // Don't create a new Promise for same ID until on going one is over. - const promise = ctxFromURLOnGoingPromise[descriptor.id] ?? (ctxFromURLOnGoingPromise[descriptor.id] = (async () => { - // NOTE: API call cached by resolveYT method automatically - const res = await resolveYT(descriptor) - if (!res) return // couldn't find it on lbry, so we're done - - const { redirect, targetPlatform } = await getExtensionSettingsAsync('redirect', 'targetPlatform') - return { descriptor, lbryPathname: res, redirect, targetPlatform } - })()) - await promise - delete ctxFromURLOnGoingPromise[descriptor.id] - return await promise -} - -// handles lbry.tv -> lbry app redirect -chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => { - const { redirect, targetPlatform } = await getExtensionSettingsAsync('redirect', 'targetPlatform'); - if (!redirect || targetPlatform !== 'app' || !changeInfo.url || !tabUrl?.startsWith('https://odysee.com/')) return; - - const url = appRedirectUrl(tabUrl, { encode: true }); - if (!url) return; - chrome.tabs.update(tabId, { url }); - alert('Opened link in LBRY App!'); // Better for UX since sometimes LBRY App doesn't take focus, if that is fixed, this can be removed - chrome.tabs.executeScript(tabId, { - code: `if (window.history.length === 1) { - window.close(); - } else { - window.history.back(); - } - document.querySelectorAll('video').forEach(v => v.pause()); - ` - }); -}); - -chrome.runtime.onMessage.addListener(({ url }: { url: string }, sender, sendResponse) => { - ctxFromURL(url).then(ctx => { - sendResponse(ctx); - }) - return true; -}) - -// relay youtube link changes to the content script -chrome.tabs.onUpdated.addListener((tabId, changeInfo, { url }) => { - if (url) ctxFromURL(url).then(ctx => chrome.tabs.sendMessage(tabId, ctx)); -}); diff --git a/src/scripts/ytContent.tsx b/src/scripts/ytContent.tsx index 3281698..d267eb8 100644 --- a/src/scripts/ytContent.tsx +++ b/src/scripts/ytContent.tsx @@ -1,179 +1,169 @@ -import { getSourcePlatfromSettingsFromHostname, TargetPlatformName, targetPlatformSettings } from '../common/settings' -import type { UpdateContext } from '../scripts/tabOnUpdated' -import { h, JSX, render } from 'preact' +import { h, render } from 'preact' +import { getExtensionSettingsAsync, getSourcePlatfromSettingsFromHostname, TargetPlatform, targetPlatformSettings } from '../common/settings' +import { parseYouTubeURLTimeString } from '../common/yt' -const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)); +const sleep = (t: number) => new Promise(resolve => setTimeout(resolve, t)) -function pauseAllVideos() { document.querySelectorAll('video').forEach(v => v.pause()); } - -interface ButtonSettings { - text: string - icon: string - style?: - { - icon?: JSX.CSSProperties - button?: JSX.CSSProperties - } -} - -const buttonSettings: Record = { - app: { - text: 'Watch on LBRY', - icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg') - }, - 'madiator.com': { - text: 'Watch on', - icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'), - style: { - button: { flexDirection: 'row-reverse' }, - icon: { transform: 'scale(1.2)' } - } - }, - odysee: { - text: 'Watch on Odysee', - icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg') - }, -}; - -interface ButtonParameters -{ - targetPlatform?: TargetPlatformName +interface WatchOnLbryButtonParameters { + targetPlatform?: TargetPlatform lbryPathname?: string time?: number } -export function WatchOnLbryButton({ targetPlatform = 'app', lbryPathname, time }: ButtonParameters = {}) { - if (!lbryPathname || !targetPlatform) return null; - const targetPlatformSetting = targetPlatformSettings[targetPlatform]; - const buttonSetting = buttonSettings[targetPlatform]; +interface Target { + platfrom: TargetPlatform + lbryPathname: string + time: number | null +} - const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`) - if (time) url.searchParams.append('t', time.toFixed(0)) +function WatchOnLbryButton({ targetPlatform, lbryPathname, time }: WatchOnLbryButtonParameters) { + if (!lbryPathname || !targetPlatform) return null + + const url = new URL(`${targetPlatform.domainPrefix}${lbryPathname}`) + if (time) url.searchParams.set('t', time.toFixed(0)) return ; + + {targetPlatform.button.text} + + +} + +function updateButton(mountPoint: HTMLDivElement, target: Target | null): void { + if (!target) return render(, mountPoint) + render(, mountPoint) +} + +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 })) + open(url, '_blank') + if (window.history.length === 1) window.close() + else window.history.back() + } + else + location.replace(url.toString()) } -let mountPoint: HTMLDivElement | null = null /** Returns a mount point for the button */ -async function findButtonMountPoint(): Promise { +async function findButtonMountPoint(): Promise { + const id = 'watch-on-lbry-button-container' let mountBefore: HTMLDivElement | null = null const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname) if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`) + const exits: HTMLDivElement | null = document.querySelector(`#${id}`) + if (exits) return exits + while (!(mountBefore = document.querySelector(sourcePlatform.htmlQueries.mountButtonBefore))) await sleep(200) - while (!(mountBefore = document.querySelector(sourcePlatform.htmlQueries.mountButtonBefore))) await sleep(200); - - const div = document.createElement('div'); - div.style.display = 'flex'; + const div = document.createElement('div') + div.id = id + div.style.display = 'flex' div.style.alignItems = 'center' mountBefore.parentElement?.insertBefore(div, mountBefore) - mountPoint = div + + return div } -let videoElement: HTMLVideoElement | null = null; async function findVideoElement() { const sourcePlatform = getSourcePlatfromSettingsFromHostname(new URL(location.href).hostname) if (!sourcePlatform) throw new Error(`Unknown source of: ${location.href}`) - - while(!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200) - - videoElement.addEventListener('timeupdate', () => updateButton(ctxCache)) + let videoElement: HTMLVideoElement | null = null + while (!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200) + return videoElement } -/** Compute the URL and determine whether or not a redirect should be performed. Delegates the redirect to callbacks. */ -let ctxCache: UpdateContext | null = null -function handleURLChange (ctx: UpdateContext | null): void { - ctxCache = ctx - updateButton(ctx) - if (ctx?.redirect) redirectTo(ctx) +// We should get this from background, so the caching works and we don't get errors in the future if yt decides to impliment CORS +async function requestLbryPathname(videoId: string) { + return await new Promise((resolve) => chrome.runtime.sendMessage({ videoId }, resolve)) } -function updateButton(ctx: UpdateContext | null): void { - if (!mountPoint) return - if (!ctx) return render(, mountPoint) - if (ctx.descriptor.type !== 'video') return; - const lbryPathname = ctx.lbryPathname - const targetPlatform = ctx.targetPlatform - let time: number = videoElement?.currentTime ?? 0 - if (time < 3) time = 0 - if (time >= (videoElement?.duration ?? 0) - 1) time = 0 +// Start +(async () => { + const settings = await getExtensionSettingsAsync() + let updater: (() => Promise) - render(, mountPoint) -} + // Listen Settings Change + chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName !== 'local') return + Object.assign(settings, Object.fromEntries(Object.entries(changes).map(([key, change]) => [key, change.newValue]))) + if (changes.redirect) await onModeChange() + }) -function redirectTo({ targetPlatform, lbryPathname }: UpdateContext): void { - - const parseYouTubeTime = (timeString: string) => { - const signs = timeString.replace(/[0-9]/g, '') - if (signs.length === 0) return timeString - const numbers = timeString.replace(/[^0-9]/g, '-').split('-') - let total = 0 - for (let i = 0; i < signs.length; i++) { - let t = parseInt(numbers[i]) - switch (signs[i]) { - case 'd': t *= 24; case 'h': t *= 60; case 'm': t *= 60; case 's': break - default: return '0' + /* + * Gets messages from background script which relays tab update events. This is because there's no sensible way to detect + * history.pushState changes from a content script + */ + // Listen URL Change + chrome.runtime.onMessage.addListener(() => updater()) + + async function getTargetByURL(url: URL) { + if (url.pathname !== '/watch') return null + + const videoId = url.searchParams.get('v') + const lbryPathname = videoId && await requestLbryPathname(videoId) + const target: Target | null = lbryPathname ? { lbryPathname, platfrom: targetPlatformSettings[settings.targetPlatform], time: null } : null + + return target + } + + let removeVideoTimeUpdateListener: (() => void) | null = null + async function onModeChange() { + let target: Target | null = null + if (settings.redirect) + updater = async () => { + const url = new URL(location.href) + target = await getTargetByURL(url) + if (!target) return + target.time = url.searchParams.has('t') ? parseYouTubeURLTimeString(url.searchParams.get('t')!) : null + redirectTo(target) + } + else { + const mountPoint = await findButtonMountPoint() + const videoElement = await findVideoElement() + + const getTime = () => videoElement.currentTime > 3 && videoElement.currentTime < videoElement.duration - 1 ? videoElement.currentTime : null + + const onTimeUpdate = () => target && updateButton(mountPoint, Object.assign(target, { time: getTime() })) + removeVideoTimeUpdateListener?.call(null) + videoElement.addEventListener('timeupdate', onTimeUpdate) + removeVideoTimeUpdateListener = () => videoElement.removeEventListener('timeupdate', onTimeUpdate) + + updater = async () => { + const url = new URL(location.href) + target = await getTargetByURL(url) + if (target) target.time = getTime() + updateButton(mountPoint, target) } - total += t } - return total.toString() + + await updater() } - const targetPlatformSetting = targetPlatformSettings[targetPlatform]; - const url = new URL(`${targetPlatformSetting.domainPrefix}${lbryPathname}`) - const time = new URL(location.href).searchParams.get('t') - - if (time) url.searchParams.append('t', parseYouTubeTime(time)) - - if (targetPlatform === 'app') - { - pauseAllVideos(); - location.assign(url); - return - } - location.replace(url.toString()); -} - - - -findButtonMountPoint().then(() => updateButton(ctxCache)) -findVideoElement().then(() => updateButton(ctxCache)) - - -/** Request UpdateContext from background */ -const requestCtxFromUrl = async (url: string) => await new Promise((resolve) => chrome.runtime.sendMessage({ url }, resolve)) - -/** Handle the location on load of the page */ -requestCtxFromUrl(location.href).then((ctx) => handleURLChange(ctx)) - -/* - * Gets messages from background script which relays tab update events. This is because there's no sensible way to detect - * history.pushState changes from a content script - */ -chrome.runtime.onMessage.addListener(async (ctx: UpdateContext) => handleURLChange(ctx)); - -/** On settings change */ -chrome.storage.onChanged.addListener(async (changes, areaName) => { - if (areaName !== 'local') return; - if (changes.targetPlatform) handleURLChange(await requestCtxFromUrl(location.href)) -}); \ No newline at end of file + await onModeChange() +})() \ No newline at end of file diff --git a/src/tools/YTtoLBRY.html b/src/tools/YTtoLBRY.html index 1488422..3a62039 100644 --- a/src/tools/YTtoLBRY.html +++ b/src/tools/YTtoLBRY.html @@ -1,13 +1,15 @@ - - - Subscription Converter - - - - -
- - + + + Subscription Converter + + + + + +
+ + + \ No newline at end of file diff --git a/src/tools/YTtoLBRY.tsx b/src/tools/YTtoLBRY.tsx index 1c053da..5faaa29 100644 --- a/src/tools/YTtoLBRY.tsx +++ b/src/tools/YTtoLBRY.tsx @@ -1,11 +1,10 @@ import { h, render } from 'preact' import { useState } from 'preact/hooks' import { getExtensionSettingsAsync, targetPlatformSettings } from '../common/settings' -import { getFileContent, ytService } from '../common/yt' +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 * @@ -13,23 +12,23 @@ import readme from './README.md' * @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 ext = file.name.split('.').pop()?.toLowerCase() + const ids = new Set(( - ext === 'xml' || ext == 'opml' ? ytService.readOpml : - ext === 'csv' ? ytService.readCsv : - ytService.readJson)(await getFileContent(file))) - const lbryUrls = await ytService.resolveById( - Array.from(ids).map(id => ({ id, type: 'channel' } as const)), - (progress) => render(, document.getElementById('root')!)); - const { targetPlatform: platform } = await getExtensionSettingsAsync('targetPlatform'); - const urlPrefix = targetPlatformSettings[platform].domainPrefix; - return lbryUrls.map(channel => urlPrefix + channel); + ext === 'xml' || ext == 'opml' ? getSubsFromOpml : + ext === 'csv' ? getSubsFromCsv : + getSubsFromJson)(await getFileContent(file))) + const lbryPathnames = 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) } function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise | void, progress: number }) { - const [file, setFile] = useState(null as File | null); - const [isLoading, setLoading] = useState(false); + const [file, setFile] = useState(null as File | null) + const [isLoading, setLoading] = useState(false) return

Select YouTube Subscriptions

@@ -37,10 +36,10 @@ function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise< setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />