Merge pull request #43 from Aenigma/feature/takeout-json

Support Google Takeout JSON for Subscription Manager
This commit is contained in:
kodxana 2020-12-04 20:37:01 +01:00 committed by GitHub
commit a51be792ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 24162 additions and 5180 deletions

29110
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,6 @@
">1%",
"not ie > 0"
],
"dependencies": {},
"devDependencies": {
"@babel/preset-typescript": "^7.10.4",
"@types/chrome": "0.0.124",
@ -30,6 +29,7 @@
"cross-env": "^7.0.2",
"jest": "^26.5.3",
"lodash": "^4.17.20",
"marked": "^1.2.5",
"node-forge": ">=0.10.0",
"node-sass": "^4.14.1",
"npm-run-all": "^4.1.5",

View file

@ -6,12 +6,24 @@ const LBRY_API_HOST = 'https://api.lbry.com';
const QUERY_CHUNK_SIZE = 300;
interface YtResolverResponse {
success: boolean
error: object | null
success: boolean;
error: object | null;
data: {
videos?: Record<string, string>
channels?: Record<string, string>
}
videos?: Record<string, string>;
channels?: Record<string, string>;
};
}
interface YtSubscription {
id: string;
etag: string;
title: string;
snippet: {
description: string;
resourceId: {
channelId: string;
};
};
}
/**
@ -41,15 +53,29 @@ 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
* @returns the channel IDs
*/
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)
.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: YtSubscription[] = JSON.parse(jsonContents);
return subscriptions.map(sub => sub.snippet.resourceId.channelId);
},
/**
* Extracts the channelID from a YT URL.
*
@ -89,7 +115,7 @@ export const ytService = {
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}`, {cache: 'force-cache'})
return fetch(`${LBRY_API_HOST}/yt/resolve?${params}`, { cache: 'force-cache' })
.then(rsp => rsp.ok ? rsp.json() : null);
}));

4
src/global.d.ts vendored Normal file
View file

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

6
src/tools/README.md Normal file
View file

@ -0,0 +1,6 @@
# Getting your subscription data
1. Go to https://takeout.google.com/settings/takeout
2. Deselect everything except `YouTube and YouTube Music` and within that only select `subscriptions`
3. Go through the process and create the export
4. Once it's exported, open the archive and find `YouTube and YouTube Music/subscriptions/subscriptions.json` and upload it to the extension

View file

@ -1,11 +1,20 @@
.body {
display: block;
margin: auto;
padding: 10px;
width: 50%;
text-align: center;
margin-bottom: 15px;
border: 3px;
body {
--color-text: whitesmoke;
--color-backround: rgb(28, 31, 34);
--color-card: #2a2e32;
--color-primary: rgb(43, 187, 144);
background-color: var(--color-backround);
color: var(--color-text);
font-size: 1rem;
}
a {
color: var(--color-primary);
}
h1, h2, h3, h4, h5, h6 {
margin: 0 0 10px;
}
.container {
@ -19,53 +28,61 @@
}
.goButton {
background-color: transparent;
position: relative;
display: inline-block;
padding: 15px 30px;
color: teal;
text-transform: uppercase;
letter-spacing: 4px;
text-decoration: none;
font-size: 24px;
border-radius: 5px;
.YTtoLBRY {
display: flex;
justify-content: center;
justify-content: space-around;
flex-direction: row;
}
.Conversion {
margin: 1rem;
width: 45em;
}
.ConversionHelp {
display: flex;
flex-direction: column;
align-items: center;
border: transparent;
font-weight: 400;
padding: 4px 15px;
margin-right: 25px;
text-align: center;
width: 100%;
transition: 0.2s;
overflow: hidden;
margin-top: 90px;
}
.goButton:hover{
color: #075656;
background: teal;
box-shadow: 0 0px 10px teal, 0 0px 40px teal, 0 0 80px teal;
.ConversionCard {
box-shadow: 0 0 0 1px rgba(16,22,26,.1), 0 1px 1px rgba(16,22,26,.2), 0 2px 6px rgba(16,22,26,.2);
border-radius: 5px;
background-color: var(--color-card);
padding: 20px;
}
.label {
font-size: 1.1rem;
margin: 15px auto;
display: block;
.btn {
color: var(--color-text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
padding: 5px 10px;
border-radius: 3px;
font-size: 14px;
min-width: 30px;
min-height: 30px;
}
.selectYtSubscriptions{
color: teal;
font: 36px Tahoma, Helvetica, Arial, Sans-Serif;
text-shadow: 0px 2px 3px rgb(0, 0, 0);
text-align: center;
margin: 90px;
.btn.btn-primary {
background-color: var(--color-primary);
}
.selectYtSubscriptions:hover{
text-shadow: 0px 2px 3px rgb(0, 255, 255);
transition: 0.2s;
}
.btn:disabled {
background-color: rgba(200, 200, 200, .5);
color: rgba(90, 90, 90, .6);
cursor: not-allowed;
}
@media (max-width: 1400px) {
.YTtoLBRY {
flex-direction: column;
}
.Conversion {
width: auto;
}
}

View file

@ -7,9 +7,7 @@
<script src="YTtoLBRY.tsx" charset="utf-8" defer></script>
</head>
<body style="background-color:#474747;">
<div class="container">
<div id='root' />
</div>
<body>
<div id="root" />
</body>
</html>

View file

@ -1,47 +1,62 @@
import { Fragment, h, render } from 'preact';
import { Fragment, h, JSX, render } from 'preact';
import { useState } from 'preact/hooks';
import { getSettingsAsync, redirectDomains } from '../common/settings';
import { YTDescriptor, getFileContent, ytService } from '../common/yt';
import { getFileContent, ytService } from '../common/yt';
import readme from './README.md';
/**
* Parses OPML file and queries the API for lbry channels
* Parses the subscription 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<string[]> {
const lbryUrls = await ytService.resolveById(...ytService.readOpml(await getFileContent(file))
.map(url => ytService.getId(url))
.filter((id): id is YTDescriptor => !!id));
async function lbryChannelsFromFile(file: File) {
const ext = file.name.split('.').pop()?.toLowerCase();
const content = await getFileContent(file);
const ids = new Set((ext === 'xml' || ext == 'opml' ? ytService.readOpml(content) : ytService.readJson(content)))
const lbryUrls = await ytService.resolveById(...Array.from(ids).map(id => ({ id, type: 'channel' } as const)));
const { redirect } = await getSettingsAsync('redirect');
const urlPrefix = redirectDomains[redirect].prefix;
return lbryUrls.map(channel => urlPrefix + channel);
}
function YTtoLBRY() {
function ConversionCard({ onSelect }: { onSelect(file: File): Promise<void> | void }) {
const [file, setFile] = useState(null as File | null);
const [lbryChannels, setLbryChannels] = useState([] as string[]);
const [isLoading, setLoading] = useState(false);
return <>
<iframe width="100%" height="400px" allowFullScreen
src="https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e" />
<div class="selectYtSubscriptions">Select Youtube Subscriptions</div>
<hr />
<input type="file" className="PickFile" onChange={e =>
setFile(e.currentTarget.files?.length ? e.currentTarget.files[0] : null)} />
<hr />
<input type="button" value="Start Conversion!" class="goButton" disabled={!file || isLoading} onClick={async () => {
return <div className='ConversionCard'>
<h2>Select YouTube Subscriptions</h2>
<div style={{ marginBottom: 10 }}>
<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);
setLbryChannels(await lbryChannelsFromOpml(file));
await onSelect(file);
setLoading(false);
}} />
<ul>
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
</ul>
</>;
</div>
}
function YTtoLBRY() {
const [lbryChannels, setLbryChannels] = useState([] as string[]);
return <div className='YTtoLBRY'>
<div className='Conversion'>
<ConversionCard onSelect={async file => setLbryChannels(await lbryChannelsFromFile(file))} />
<ul>
{lbryChannels.map((x, i) => <li key={i} children={<a href={x} children={x} />} />)}
</ul>
</div>
<div className='ConversionHelp'>
<iframe width='712px' height='400px' allowFullScreen
src='https://lbry.tv/$/embed/howtouseconverter/c9827448d6ac7a74ecdb972c5cdf9ddaf648a28e' />
<section dangerouslySetInnerHTML={{ __html: readme }} />
</div>
</div>
}
render(<YTtoLBRY />, document.getElementById('root')!);