diff --git a/package-lock.json b/package-lock.json index 7c19896..9d54c00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1736,6 +1736,12 @@ "integrity": "sha512-iUxzm1meBm3stxUMzRqgOVHjj4Kgpgu5w9fm4X7kPRfSgVRzythsucEN7/jtOo8SQzm+HfcxWWzJS0mJDH/3DQ==", "dev": true }, + "@types/lodash": { + "version": "4.14.162", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.162.tgz", + "integrity": "sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig==", + "dev": true + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", diff --git a/package.json b/package.json index 405114a..f0c995d 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "dependencies": {}, "devDependencies": { "@types/chrome": "0.0.124", + "@types/lodash": "^4.14.162", "cpx": "^1.5.0", "cross-env": "^7.0.2", + "lodash": "^4.17.20", "npm-run-all": "^4.1.5", "parcel-bundler": "^1.12.4", "braces": ">=2.3.1", diff --git a/src/common/yt.ts b/src/common/yt.ts new file mode 100644 index 0000000..31800a9 --- /dev/null +++ b/src/common/yt.ts @@ -0,0 +1,97 @@ +import { chunk, groupBy, pickBy } from 'lodash'; + +const LBRY_API_HOST = 'https://api.lbry.com'; +const QUERY_CHUNK_SIZE = 325; + +interface YtResolverResponse { + success: boolean + error: object | null + data: { + videos?: Record + channels?: Record + } +} + +/** + * @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 YTDescriptor { + id: string + type: 'channel' | 'video' +} + +export const ytService = { + + /** + * Reads the array of YT channels from an OPML file + * + * @param opmlContents an opml file as as tring + * @returns the channel URLs + */ + readOpml(opmlContents: string): string[] { + const opml = new DOMParser().parseFromString(opmlContents, 'application/xml'); + return Array.from(opml.querySelectorAll('outline > outline')) + .map(outline => outline.getAttribute('xmlUrl')) + .filter((url): url is string => !!url); // we don't want it if it's empty + }, + + /** + * 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): YTDescriptor | 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 descriptors YT resource IDs to check + * @returns a promise with the list of channels that were found on lbry + */ + async resolveById(...descriptors: YTDescriptor[]): Promise { + const descChunks = chunk(descriptors, QUERY_CHUNK_SIZE); + const responses: (YtResolverResponse | null)[] = await Promise.all(descChunks.map(descChunk => { + const groups = groupBy(descChunk, d => d.type); + const params = new URLSearchParams(pickBy({ + video_ids: groups['video']?.map(s => s.id).join(','), + channel_ids: groups['channel']?.map(s => s.id).join(','), + })); + return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`) + .then(rsp => rsp.ok ? rsp.json() : null); + })); + + return responses.filter((rsp): rsp is YtResolverResponse => !!rsp) + .flatMap(rsp => [...Object.values(rsp.data.videos || {}), ...Object.values(rsp.data.channels || {})]); // flatten the results into a 1D array + }, +}; diff --git a/src/scripts/tabOnUpdated.ts b/src/scripts/tabOnUpdated.ts index 4e02b68..3d39bdc 100644 --- a/src/scripts/tabOnUpdated.ts +++ b/src/scripts/tabOnUpdated.ts @@ -1,4 +1,5 @@ import { getSettingsAsync, redirectDomains } from "../common/settings"; +import { ytService } from "../common/yt"; chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => { const { enabled, redirect } = await getSettingsAsync('enabled', 'redirect'); @@ -37,19 +38,12 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => } return; } - const { id, type } = getId(tabUrl); - if (!id) return; - - const url = `https://api.lbry.com/yt/resolve?${type}_ids=${id}`; - const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } }); - const json = await response.json(); - console.log(json); - const title = json.data[`${type}s`][id]; + const descriptor = ytService.getId(tabUrl); + if (!descriptor) return; + const title = (await ytService.resolveById(descriptor))[0] if (!title) return; console.log(title); - - console.log(redirect); let newUrl; if (redirect === "lbry.tv") { newUrl = `${urlPrefix}${title.replace(/^lbry:\/\//, "").replace(/#/g, ":")}?src=watch-on-lbry`; @@ -58,26 +52,3 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, { url: tabUrl }) => } chrome.tabs.update(tabId, { url: newUrl }); }); - -function getId(url) { - const videoId = getVideoId(url); - if (videoId) return { id: videoId, type: "video" }; - const channelId = getChannelId(url); - if (channelId) return { id: channelId, type: "channel" }; - return {}; // Equivalent of returning null -} - -function getVideoId(url) { - const regex = /watch\/?\?.*v=([^\s&]*)/; - const match = url.match(regex); - return match ? match[1] : null; // match[1] is the videoId -} - -function getChannelId(url) { - const regex = /channel\/([^\s?]*)/; - const match = url.match(regex); - return match ? match[1] : null; // match[1] is the channelId -} - -function getNewUrl(title) { -} diff --git a/src/tools/YTtoLBRY.html b/src/tools/YTtoLBRY.html index 63cf668..0b2389b 100644 --- a/src/tools/YTtoLBRY.html +++ b/src/tools/YTtoLBRY.html @@ -4,7 +4,7 @@ Subscribtion Converter - + diff --git a/src/tools/YTtoLBRY.js b/src/tools/YTtoLBRY.js deleted file mode 100644 index ea4c8f3..0000000 --- a/src/tools/YTtoLBRY.js +++ /dev/null @@ -1,98 +0,0 @@ -import { redirectDomains } from '../common/settings' - -console.log("YouTube To LBRY finder!"); -var ytChannelsString = ""; -var lbryChannelsString = ""; -let lbryArray = []; -var toCheck = []; -let tempJson = {"0":0}; -var subconv; -var goButton; -var lbryChannelList; - -window.addEventListener('load', (event) => { - subconv = document.getElementById("subconv"); - goButton = document.getElementById("go-button"); - lbryChannelList = document.getElementById("lbry-channel-list"); - - goButton.addEventListener('click', () => onGoClicked()); -}); - -function getFile(file) { - const reader = new FileReader(); - reader.addEventListener('load', (event) => { - var content = event.target.result; - content = content.replace('',''); - content = content.replace('',''); - content = content.replace('/>',' '); - splitChannels = content.split("="); - toCheck = []; - for (var i = 0; i <= splitChannels.length-1; i++) { - tempChannel = splitChannels[i]; - if (tempChannel.indexOf("outline text")>=29) { - toCheck[toCheck.length] = tempChannel.slice(0, tempChannel.indexOf("outline text")-5); - } - } - lbryAPIrequest(); - }); - reader.readAsText(file); -} - -function onGoClicked() { - if (subconv.files.length > 0) { - getFile(subconv.files[0]); - } -} - -function lbryAPIrequest() { - // Clear current channel list - while (lbryChannelList.lastElementChild) { - lbryChannelList.removeChild(lbryChannelList.lastElementChild); - } - - chrome.storage.local.get('redirect', redirect => { - validateChannels(toCheck, redirect.redirect, []); - }); -} - -function validateChannels(channels, redirect, validatedChannels) { - const requestSize = 325; - var channelsString = ""; - for (let i = 0; i < channels.length && i < requestSize; i++) { - channelsString += `${channelsString.length > 0 ? ',' : ''}${channels[i]}` - } - request = new XMLHttpRequest(); - request.open("GET", `https://api.lbry.com/yt/resolve?channel_ids={${channelsString}}`); - request.send(); - request.onload = () => { - if (request.status == 200) { - var testChannels = JSON.parse(request.responseText).data.channels; - Object.keys(testChannels).map((testChannelKey) => { - let testChannel = testChannels[testChannelKey]; - if (testChannel != null) { - let link = redirectDomains[redirect].prefix + testChannel; - validatedChannels.push(link); - let li = document.createElement('li'); - let a = document.createElement('a'); - a.href = link; - a.innerText = link; - li.appendChild(a); - lbryChannelList.appendChild(li); - } - }); - } - if (requestSize < channels.length) { - channels.splice(0, requestSize); - validateChannels(channels, redirect, validatedChannels); - } else if (validatedChannels.length === 0) { - let li = document.createElement('li'); - li.innerText = "No channels found :("; - lbryChannelList.appendChild(li); - } - } -} - -chrome.storage.local.get('redirect', redirect => { - console.log(redirect); -}) diff --git a/src/tools/YTtoLBRY.ts b/src/tools/YTtoLBRY.ts new file mode 100644 index 0000000..ebb2d5b --- /dev/null +++ b/src/tools/YTtoLBRY.ts @@ -0,0 +1,36 @@ +import { getSettingsAsync, redirectDomains } from '../common/settings'; +import { getFileContent, YTDescriptor, ytService } from '../common/yt'; + +/** + * Parses OPML file and queries the API for lbry channels + * + * @param file to read + * @returns a promise with the list of channels that were found on lbry + */ +async function lbryChannelsFromOpml(file: File): Promise { + const lbryUrls = await ytService.resolveById(...ytService.readOpml(await getFileContent(file)) + .map(url => ytService.getId(url)) + .filter((id): id is YTDescriptor => !!id)); + + const { redirect } = await getSettingsAsync('redirect'); + const urlPrefix = redirectDomains[redirect].prefix; + return lbryUrls.map(channel => urlPrefix + channel); +} + +window.addEventListener('load', () => { + const subconv = document.getElementById('subconv') as HTMLInputElement; + const goButton = document.getElementById('go-button')!; + const lbryChannelList = document.getElementById('lbry-channel-list')!; + goButton.addEventListener('click', async () => { + const files = subconv.files; + if (!files || files.length <= 0) return; + + const resultsList = await lbryChannelsFromOpml(files[0]); + + if (resultsList.length === 0) { + lbryChannelList.innerHTML = `
  • No channels found :(
  • `; + return; + } + lbryChannelList.innerHTML = resultsList.map(link => `
  • ${link}
  • `).join('\n'); + }); +});