Started invidious import

This commit is contained in:
WardPearce
2026-03-06 23:12:39 +13:00
parent 744517cf88
commit 73e1e609c8
6 changed files with 156 additions and 29 deletions
+5 -5
View File
@@ -257,15 +257,15 @@ export async function amSubscribed(
export async function postSubscribe(
authorId: string,
fetchOptions: RequestInit = {},
bypassYTBackend: boolean = false
authorName: string | undefined = undefined,
fetchOptions: RequestInit = {}
) {
if (isYTBackend() && !bypassYTBackend) {
if (isYTBackend()) {
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return postSubscribeBackend(authorId);
return postSubscribeBackend(authorId, authorName);
}
return postSubscribeYTjs(authorId);
return postSubscribeYTjs(authorId, authorName);
}
return postSubscribeInvidious(authorId, fetchOptions);
@@ -7,16 +7,14 @@
engineFallbacksStore,
engineMaxConcurrentChannelsStore,
invidiousInstanceStore,
backendInUseStore,
rawMasterKeyStore
backendInUseStore
} from '$lib/store';
import { useEngineFallback, type EngineFallback } from '$lib/api/misc';
import { get } from 'svelte/store';
import { getSubscriptions, postSubscribe } from '$lib/api';
import { postSubscribeYTjs } from '$lib/api/youtubejs/subscriptions';
import { getSubscriptions } from '$lib/api';
import { addToast } from '../Toast.svelte';
import { isOwnBackend } from '$lib/shared';
import { postSubscribeBackend } from '$lib/api/backend/subscriptions';
import { importSubscriptions } from '$lib/importExport';
import { postSubscribeInvidious } from '$lib/api/invidious/subscribe';
const engineFallbacks: EngineFallback[] = [
'Channel',
@@ -56,20 +54,7 @@
}
});
let subscribeFunction: (id: string, name: string) => Promise<void>;
if (isOwnBackend()?.internalAuth && $rawMasterKeyStore) {
subscribeFunction = postSubscribeBackend;
} else {
subscribeFunction = postSubscribeYTjs;
}
const subPromises: Promise<void>[] = [];
importedSubs.forEach((sub) => {
subPromises.push(subscribeFunction(sub.authorId, sub.author));
});
await Promise.all(subPromises);
await importSubscriptions(importedSubs);
addToast({
data: {
@@ -88,9 +73,9 @@
});
const subPromises: Promise<void>[] = [];
subs.forEach((sub) => {
subPromises.push(postSubscribe(sub.authorId, {}, true));
});
for (const sub of subs) {
subPromises.push(postSubscribeInvidious(sub.authorId));
}
await Promise.all(subPromises);
@@ -0,0 +1,21 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import { importSubscriptionsFromFile } from '$lib/importExport';
</script>
<div>
<button>
<i>attach_file</i>
<span>{$_('layout.export.importSub')}</span>
</button>
<input
onchange={async (event: Event) => {
const files = (event.target as HTMLInputElement).files;
if (files?.length === 0 || !files) return;
await importSubscriptionsFromFile(files[0]);
}}
type="file"
/>
</div>
<p><i>info</i> {$_('layout.export.importSubSupported')}</p>
@@ -15,6 +15,7 @@
import { Tabs } from 'melt/builders';
import { mergeAttrs } from 'melt';
import Filters from './Filters.svelte';
import ExportImport from './ExportImport.svelte';
type TabCategories =
| 'interface'
@@ -25,7 +26,8 @@
| 'about'
| 'engine'
| 'account'
| 'filters';
| 'filters'
| 'export';
const tabCategories: Tabs<TabCategories> = new Tabs({
value: 'interface',
@@ -50,6 +52,12 @@
icon: 'keyboard_double_arrow_down',
component: DeArrow
},
{
id: 'export',
label: $_('layout.export.title'),
icon: 'file_export',
component: ExportImport
},
{
id: 'about',
label: $_('layout.about'),
@@ -271,6 +271,11 @@
"addConditional": "Add conditional",
"addFilter": "Add filter"
},
"export": {
"title": "Export/Import",
"importSub": "Import subscriptions",
"importSubSupported": "YouTube CSV/OPML, Invidious OPML/JSON, Freetube database & NewPipe JSON supported"
},
"deArrow": {
"title": "DeArrow",
"thumbnailInstanceUrl": "Thumbnail instance URL",
+108
View File
@@ -0,0 +1,108 @@
import z from 'zod';
import type { Subscription } from './api/model';
import { getChannel, postSubscribe } from './api';
const zInvidiousSubs = z.object({
subscriptions: z.array(z.string())
});
const zFreetubeSubs = z.object({
subscriptions: z.array(
z.object({
id: z.string(),
name: z.string(),
thumbnail: z.string()
})
)
});
export async function importSubscriptions(subscriptions: Subscription[]) {
const subPromises: Promise<void>[] = [];
for (const sub of subscriptions) {
subPromises.push(postSubscribe(sub.authorId, sub.author));
}
await Promise.all(subPromises);
}
export async function importSubscriptionsFromFile(file: File) {
const fileContents = await file.text();
const subsToImport: Subscription[] = [];
if (fileContents.startsWith('<opml')) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(fileContents, 'text/xml');
const opmlSubs = xmlDoc.getElementsByTagName('outline');
for (const sub of opmlSubs) {
const channelUrl = sub.getAttribute('xmlUrl');
const channelName = sub.getAttribute('text') || sub.getAttribute('title');
if (!channelUrl || !channelName) continue;
let channelUrlObj: URL | undefined;
try {
channelUrlObj = new URL(channelUrl);
} catch {
// Handled outsidde of error
}
if (!channelUrlObj) continue;
let channelId: string | null | undefined = channelUrlObj.searchParams.get('channel_id');
if (!channelId) {
channelId = channelUrlObj.pathname.split('/')[3];
}
if (typeof channelId !== 'string') continue;
subsToImport.push({
author: channelName,
authorId: channelId
});
}
} else {
let fileJson: Record<any, any> | undefined;
try {
fileJson = JSON.parse(fileContents);
} catch {
// Continue regardless
}
if (fileJson) {
const invidiousSubs = zInvidiousSubs.safeParse(fileJson);
const freetubeSubs = zFreetubeSubs.safeParse(fileJson);
if (invidiousSubs.success) {
for (const authorId of invidiousSubs.data.subscriptions) {
try {
const channel = await getChannel(authorId);
subsToImport.push({
authorId,
author: channel.author
});
} catch {
// Continue regardless
}
}
} else if (freetubeSubs.success) {
for (const sub of freetubeSubs.data.subscriptions) {
subsToImport.push({
author: sub.name,
authorId: sub.id
});
}
}
}
}
if (subsToImport.length > 0) {
await importSubscriptions(subsToImport);
return;
}
throw new Error('Unable to determine imported file type');
}