diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index ea9c5dc1..e2d3854e 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -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); diff --git a/materialious/src/lib/components/settings/Engine.svelte b/materialious/src/lib/components/settings/Engine.svelte index 2c81fa92..56cb9370 100644 --- a/materialious/src/lib/components/settings/Engine.svelte +++ b/materialious/src/lib/components/settings/Engine.svelte @@ -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; - - if (isOwnBackend()?.internalAuth && $rawMasterKeyStore) { - subscribeFunction = postSubscribeBackend; - } else { - subscribeFunction = postSubscribeYTjs; - } - - const subPromises: Promise[] = []; - 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[] = []; - subs.forEach((sub) => { - subPromises.push(postSubscribe(sub.authorId, {}, true)); - }); + for (const sub of subs) { + subPromises.push(postSubscribeInvidious(sub.authorId)); + } await Promise.all(subPromises); diff --git a/materialious/src/lib/components/settings/ExportImport.svelte b/materialious/src/lib/components/settings/ExportImport.svelte new file mode 100644 index 00000000..cecd1121 --- /dev/null +++ b/materialious/src/lib/components/settings/ExportImport.svelte @@ -0,0 +1,21 @@ + + +
+ + { + const files = (event.target as HTMLInputElement).files; + if (files?.length === 0 || !files) return; + + await importSubscriptionsFromFile(files[0]); + }} + type="file" + /> +
+

info {$_('layout.export.importSubSupported')}

diff --git a/materialious/src/lib/components/settings/Settings.svelte b/materialious/src/lib/components/settings/Settings.svelte index 36388a3c..9609e3fd 100644 --- a/materialious/src/lib/components/settings/Settings.svelte +++ b/materialious/src/lib/components/settings/Settings.svelte @@ -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 = 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'), diff --git a/materialious/src/lib/i18n/locales/en.json b/materialious/src/lib/i18n/locales/en.json index 47f75e40..31715ece 100644 --- a/materialious/src/lib/i18n/locales/en.json +++ b/materialious/src/lib/i18n/locales/en.json @@ -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", diff --git a/materialious/src/lib/importExport.ts b/materialious/src/lib/importExport.ts new file mode 100644 index 00000000..07a5377a --- /dev/null +++ b/materialious/src/lib/importExport.ts @@ -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[] = []; + 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(' | 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'); +}