Merge pull request #88 from DeepDoge/1.7.6

Refactor and bug fixes
This commit is contained in:
kodxana 2022-01-10 17:36:37 +01:00 committed by GitHub
commit 889c64c0a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 585 additions and 659 deletions

View file

@ -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<T extends string | SelectionOption = string> {
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<T extends string | SelectionOption = string>({ name = 'buttonRadio', onChange, options, value }: ButtonRadioProps<T>) {
/** If it's a string, return the string, if it's a SelectionOption get the selection option property */
@ -27,5 +27,5 @@ export default function ButtonRadio<T extends string | SelectionOption = string>
<label>{display}</label>
</div>
)}
</div>;
</div>
}

View file

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

View file

@ -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;
: 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 []
}

View file

@ -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<K extends Array<keyof ExtensionSettings>>(...keys: K): Promise<Pick<ExtensionSettings, K[number]>> {
return new Promise(resolve => chrome.storage.local.get(keys, o => resolve(o as any)));
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 TargetPlatformSettings {
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<TargetPlatformName, TargetPlatformSettings> = {
export const targetPlatformSettings: Record<TargetPlatformName, TargetPlatform> = {
'madiator.com': {
domainPrefix: 'https://madiator.com/',
displayName: 'Madiator.com',
theme: '#075656'
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'
theme: '#1e013b',
button: {
text: 'Watch on Odysee',
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
}
},
app: {
domainPrefix: 'lbry://',
displayName: 'LBRY App',
theme: '#075656'
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<keyof typeof targetPlatformSettings, string>, TargetPlatformSettings][]
return Object.entries(targetPlatformSettings) as any as [Extract<keyof typeof targetPlatformSettings, string>, 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<SourcePlatfromName, SourcePlatfromSettings> = {
export const sourcePlatfromSettings: Record<SourcePlatformName, SourcePlatform> = {
"yewtu.be": {
hostnames: ['yewtu.be'],
htmlQueries: {
@ -83,15 +109,13 @@ 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,7 +126,7 @@ export interface YTUrlResolver
export const ytUrlResolversSettings: Record<YTUrlResolverName, YTUrlResolver> = {
lbryInc: {
name: "LBRY Inc.",
name: "Odysee",
hostname: "api.odysee.com",
functions: {
getChannelId: {

View file

@ -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<T extends object>(initial: T) {
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial);
const [state, dispatch] = useReducer((state, nstate: Partial<T>) => ({ ...state, ...nstate }), initial)
// register change listeners, gets current values, and cleans up the listeners on unload
useEffect(() => {
const changeListener = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== 'local') return;
if (areaName !== 'local') return
const changeSet = Object.keys(changes)
.filter(k => Object.keys(initial).includes(k))
.map(k => [k, changes[k].newValue]);
if (changeSet.length === 0) return; // no changes; no use dispatching
dispatch(Object.fromEntries(changeSet));
};
chrome.storage.onChanged.addListener(changeListener);
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>));
return () => chrome.storage.onChanged.removeListener(changeListener);
}, []);
.map(k => [k, changes[k].newValue])
if (changeSet.length === 0) return // no changes; no use dispatching
dispatch(Object.fromEntries(changeSet))
}
chrome.storage.onChanged.addListener(changeListener)
chrome.storage.local.get(Object.keys(initial), o => dispatch(o as Partial<T>))
return () => chrome.storage.onChanged.removeListener(changeListener)
}, [])
return state;
return state
}
/** A hook to read watch on lbry settings from local storage */
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS);
export const useLbrySettings = () => useSettings(DEFAULT_SETTINGS)

View file

@ -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<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', event => resolve(event.target?.result as string || ''));
reader.addEventListener('error', () => {
reader.abort();
reject(new DOMException(`Could not read ${file.name}`));
});
reader.readAsText(file);
});
}
export interface 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<void>
{
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<string | null>
{
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<YtIdResolverDescriptor['type'], typeof descriptorsWithIndex | null> = 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<T>(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<string>(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<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 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
}
}

101
src/common/yt/index.ts Normal file
View file

@ -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<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', event => resolve(event.target?.result as string || ''))
reader.addEventListener('error', () => {
reader.abort()
reject(new DOMException(`Could not read ${file.name}`))
})
reader.readAsText(file)
})
}
/**
* Reads the array of YT channels from an OPML file
*
* @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
}

48
src/common/yt/urlCache.ts Normal file
View file

@ -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<void> {
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<string | null> {
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 }

104
src/common/yt/urlResolve.ts Normal file
View file

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

4
src/global.d.ts vendored
View file

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

View file

@ -25,7 +25,7 @@
"background": {
"scripts": [
"scripts/storageSetup.js",
"scripts/tabOnUpdated.js"
"scripts/background.js"
],
"persistent": false
},

View file

@ -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 = <K extends keyof ExtensionSettings>(setting: K, value: ExtensionSettings[K]) => chrome.storage.local.set({ [setting]: value });
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 }));
.map(([value, { displayName: display }]) => ({ value, display }))
const ytUrlResolverOptions: SelectionOption[] = getYtUrlResolversSettingsEntiries()
.map(([value, { name: display }]) => ({ value, display }));
.map(([value, { name: display }]) => ({ value, display }))
function WatchOnLbryPopup() {
const { redirect, targetPlatform, urlResolver } = useLbrySettings();
const { redirect, targetPlatform, urlResolver } = useLbrySettings()
return <div className='container'>
<section>
@ -39,7 +39,7 @@ function WatchOnLbryPopup() {
<button type='button' className='btn1 button is-primary'>Subscriptions Converter</button>
</a>
</section>
</div>;
</div>
}
render(<WatchOnLbryPopup />, document.getElementById('root')!);
render(<WatchOnLbryPopup />, document.getElementById('root')!)

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

@ -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<string, Promise<string | void>> = {}
async function lbryPathnameFromVideoId(videoId: string): Promise<string | void> {
// 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, {}))

View file

@ -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<keyof ExtensionSettings>);
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)

View file

@ -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<string, Promise<UpdateContext | void>> = {}
async function ctxFromURL(href: string): Promise<UpdateContext | void> {
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));
});

View file

@ -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<HTMLVideoElement>('video').forEach(v => v.pause()); }
interface ButtonSettings {
text: string
icon: string
style?:
{
icon?: JSX.CSSProperties
button?: JSX.CSSProperties
}
}
const buttonSettings: Record<TargetPlatformName, ButtonSettings> = {
app: {
text: 'Watch on LBRY',
icon: chrome.runtime.getURL('icons/lbry/lbry-logo.svg')
},
'madiator.com': {
text: 'Watch on',
icon: chrome.runtime.getURL('icons/lbry/madiator-logo.svg'),
style: {
button: { flexDirection: 'row-reverse' },
icon: { transform: 'scale(1.2)' }
}
},
odysee: {
text: 'Watch on Odysee',
icon: chrome.runtime.getURL('icons/lbry/odysee-logo.svg')
},
};
interface 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 <div style={{ display: 'flex', justifyContent: 'center', flexDirection: 'column' }}>
<a href={`${url.toString()}`} onClick={pauseAllVideos} role='button'
<a href={`${url.toString()}`} role='button'
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
borderRadius: '2px',
backgroundColor: targetPlatformSetting.theme,
backgroundColor: targetPlatform.theme,
border: '0',
color: 'whitesmoke',
padding: '10px 16px',
marginRight: '4px',
fontSize: '14px',
textDecoration: 'none',
...buttonSetting.style?.button,
...targetPlatform.button.style?.button,
}}>
<img src={buttonSetting.icon} height={16}
style={{ transform: 'scale(1.5)', ...buttonSetting.style?.icon }} />
<span>{buttonSetting.text}</span>
<img src={targetPlatform.button.icon} height={16}
style={{ transform: 'scale(1.5)', ...targetPlatform.button.style?.icon }} />
<span>{targetPlatform.button.text}</span>
</a>
</div>;
</div>
}
function updateButton(mountPoint: HTMLDivElement, target: Target | null): void {
if (!target) return render(<WatchOnLbryButton />, mountPoint)
render(<WatchOnLbryButton targetPlatform={target.platfrom} lbryPathname={target.lbryPathname} time={target.time ?? undefined} />, 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<HTMLDivElement | void> {
async function findButtonMountPoint(): Promise<HTMLDivElement> {
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}`)
let videoElement: HTMLVideoElement | null = null
while (!(videoElement = document.querySelector(sourcePlatform.htmlQueries.videoPlayer))) await sleep(200)
videoElement.addEventListener('timeupdate', () => updateButton(ctxCache))
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<string | null>((resolve) => chrome.runtime.sendMessage({ videoId }, resolve))
}
function updateButton(ctx: UpdateContext | null): void {
if (!mountPoint) return
if (!ctx) return render(<WatchOnLbryButton />, 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<void>)
render(<WatchOnLbryButton targetPlatform={targetPlatform} lbryPathname={lbryPathname} time={time || undefined} />, mountPoint)
}
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'
}
total += t
}
return total.toString()
}
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<UpdateContext | null>((resolve) => chrome.runtime.sendMessage({ url }, resolve))
/** Handle the location on load of the page */
requestCtxFromUrl(location.href).then((ctx) => handleURLChange(ctx))
// 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()
})
/*
* 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));
// Listen URL Change
chrome.runtime.onMessage.addListener(() => updater())
/** On settings change */
chrome.storage.onChanged.addListener(async (changes, areaName) => {
if (areaName !== 'local') return;
if (changes.targetPlatform) handleURLChange(await requestCtxFromUrl(location.href))
});
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)
}
}
await updater()
}
await onModeChange()
})()

View file

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Subscription Converter</title>
@ -10,4 +11,5 @@
<body>
<div id="root" />
</body>
</html>

View file

@ -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(
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(<YTtoLBRY progress={progress} />, document.getElementById('root')!));
const { targetPlatform: platform } = await getExtensionSettingsAsync('targetPlatform');
const urlPrefix = targetPlatformSettings[platform].domainPrefix;
return lbryUrls.map(channel => urlPrefix + channel);
(progress) => render(<YTtoLBRY progress={progress} />, 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> | 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 <div className='ConversionCard'>
<h2>Select YouTube Subscriptions</h2>
@ -37,10 +36,10 @@ function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<
<input type='file' onChange={e => setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />
</div>
<button class='btn btn-primary' children='Start Conversion!' disabled={!file || isLoading} onClick={async () => {
if (!file) return;
setLoading(true);
await onSelect(file);
setLoading(false);
if (!file) return
setLoading(true)
await onSelect(file)
setLoading(false)
}} />
<div class="progress-text">
{progress > 0 ? `${(progress * 100).toFixed(1)}%` : ''}
@ -49,7 +48,7 @@ function ConversionCard({ onSelect, progress }: { onSelect(file: File): Promise<
}
function YTtoLBRY({ progress }: { progress: number }) {
const [lbryChannels, setLbryChannels] = useState([] as string[]);
const [lbryChannels, setLbryChannels] = useState([] as string[])
return <div className='YTtoLBRY'>
<div className='Conversion'>
@ -66,4 +65,4 @@ function YTtoLBRY({ progress }: { progress: number }) {
</div>
}
render(<YTtoLBRY progress={0} />, document.getElementById('root')!);
render(<YTtoLBRY progress={0} />, document.getElementById('root')!)