Merge pull request #1530 from Materialious/update/1.16.12

Update/1.16.12
This commit is contained in:
Ward
2026-03-07 01:13:26 +13:00
committed by GitHub
19 changed files with 512 additions and 134 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ volumes:
```
### Overwriting Materialious defaults
Materialious lets you customize the default settings by overriding them with `PUBLIC_DEFAULT_SETTINGS`. To configure this easily, go to **Settings****Interface** and click "Export to JSON." For more details, check the [SETTINGS](./SETTINGS.md) page.
Materialious lets you customize the default settings by overriding them with `PUBLIC_DEFAULT_SETTINGS`. To configure this easily, go to **Settings****Export/Import** and click "Export to clipboard" under the "Settings" header. For more details, check the [SETTINGS](./SETTINGS.md) page.
**Please note:** These overwrites only apply on 1st load & won't replace existing configuration stored in browser local storage.
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 228
versionName "1.16.11"
versionCode 229
versionName "1.16.12"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -70,6 +70,10 @@
<content_attribute id="social-contacts">intense</content_attribute>
</content_rating>
<releases>
<release version="1.16.12" date="2026-3-06">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.12</url>
</release>
<release version="1.16.11" date="2026-3-06">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.11</url>
</release>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.16.11",
"version": "1.16.12",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
@@ -44,4 +44,4 @@
"capacitor",
"electron"
]
}
}
+20 -2
View File
@@ -1,12 +1,12 @@
{
"name": "materialious",
"version": "1.16.8",
"version": "1.16.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "materialious",
"version": "1.16.8",
"version": "1.16.12",
"hasInstallScript": true,
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
@@ -47,6 +47,7 @@
"melt": "^0.44.0",
"mousetrap": "^1.6.5",
"mysql2": "^3.18.2",
"papaparse": "^5.5.3",
"pg": "^8.19.0",
"pg-hstore": "^2.3.4",
"safe-regex2": "^5.0.0",
@@ -76,6 +77,7 @@
"@types/human-number": "^1.0.2",
"@types/jsdom": "^28.0.0",
"@types/mousetrap": "^1.6.15",
"@types/papaparse": "^5.5.2",
"@vite-pwa/sveltekit": "^1.0.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
@@ -6125,6 +6127,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/papaparse": {
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
"integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
@@ -14525,6 +14537,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.16.11",
"version": "1.16.12",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
@@ -30,6 +30,7 @@
"@types/human-number": "^1.0.2",
"@types/jsdom": "^28.0.0",
"@types/mousetrap": "^1.6.15",
"@types/papaparse": "^5.5.2",
"@vite-pwa/sveltekit": "^1.0.0",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
@@ -85,6 +86,7 @@
"melt": "^0.44.0",
"mousetrap": "^1.6.5",
"mysql2": "^3.18.2",
"papaparse": "^5.5.3",
"pg": "^8.19.0",
"pg-hstore": "^2.3.4",
"safe-regex2": "^5.0.0",
+7 -10
View File
@@ -220,11 +220,8 @@ export async function notificationsMarkAsRead(fetchOptions: RequestInit = {}) {
return notificationsMarkAsReadInvidious(fetchOptions);
}
export async function getSubscriptions(
fetchOptions: RequestInit = {},
bypassYTBackend: boolean = false
): Promise<Subscription[]> {
if (isYTBackend() && !bypassYTBackend) {
export async function getSubscriptions(fetchOptions: RequestInit = {}): Promise<Subscription[]> {
if (isYTBackend()) {
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return (await getSubscriptionsBackend()).map((sub) => {
return {
@@ -257,15 +254,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);
+1 -1
View File
@@ -11,7 +11,7 @@
action?: ToastAction;
}
const toaster = new Toaster<ToastData>();
export const toaster = new Toaster<ToastData>();
export const addToast = toaster.addToast;
</script>
@@ -1,22 +1,14 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import {
invidiousAuthStore,
engineCooldownYTStore,
engineCullYTStore,
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 { addToast } from '../Toast.svelte';
import { isOwnBackend } from '$lib/shared';
import { postSubscribeBackend } from '$lib/api/backend/subscriptions';
const engineFallbacks: EngineFallback[] = [
'Channel',
@@ -46,60 +38,6 @@
engineFallbacksStore.set(enabledFallbacks);
}
async function importInvidiousSubs() {
const importedSubs = await getSubscriptions({}, true);
addToast({
data: {
text: $_('layout.backendEngine.importingToMaterialious')
}
});
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);
addToast({
data: {
text: $_('layout.backendEngine.importingToMaterialiousFinished')
}
});
}
async function exportMaterialiousSubs() {
const subs = await getSubscriptions();
addToast({
data: {
text: $_('layout.backendEngine.exportingToInvidious')
}
});
const subPromises: Promise<void>[] = [];
subs.forEach((sub) => {
subPromises.push(postSubscribe(sub.authorId, {}, true));
});
await Promise.all(subPromises);
addToast({
data: {
text: $_('layout.backendEngine.exportingToInvidiousFinished')
}
});
}
</script>
<article class="error-container">
@@ -107,7 +45,7 @@
</article>
{#if $backendInUseStore === 'yt'}
<h6>Feed</h6>
<h3>Feed</h3>
<div class="field label prefix surface-container-highest">
<i>view_stream</i>
<input
@@ -144,25 +82,8 @@
/>
<label for="concurrent">{$_('layout.backendEngine.concurrent')}</label>
</div>
{#if $invidiousAuthStore && $invidiousInstanceStore}
<h6>{$_('layout.backendEngine.importExport')}</h6>
<div class="space"></div>
<button onclick={exportMaterialiousSubs} class="surface-container-highest">
<i>upload</i>
<div>{$_('layout.backendEngine.exportToInvidious')}</div>
</button>
<div class="space"></div>
<button onclick={importInvidiousSubs} class="surface-container-highest">
<i>download</i>
<div>{$_('layout.backendEngine.importToMaterialious')}</div>
</button>
{/if}
{:else}
<h6>{$_('layout.backendEngine.fallbacks')}</h6>
<h3>{$_('layout.backendEngine.fallbacks')}</h3>
{#each engineFallbacks as fallback (fallback)}
<nav class="no-padding">
<div class="max">
@@ -0,0 +1,181 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import { exportSubscriptionsAsFile, importSubscriptionsFromFile } from '$lib/importExport';
import { addToast, toaster } from '../Toast.svelte';
import { getSubscriptions } from '$lib/api';
import { importSubscriptions } from '$lib/importExport';
import { postSubscribeInvidious } from '$lib/api/invidious/subscribe';
import { getSubscriptionsInvidious } from '$lib/api/invidious/feed';
import { backendInUseStore, invidiousAuthStore, invidiousInstanceStore } from '$lib/store';
import { Clipboard } from '@capacitor/clipboard';
import { loadSettingsFromFile, settingsToJson } from '$lib/externalSettings';
import { downloadStringAsFile } from '$lib/misc';
async function importInvidiousSubs() {
const importedSubs = await getSubscriptionsInvidious();
addToast({
data: {
text: $_('layout.backendEngine.importingToMaterialious')
},
id: 'import-to-materialious',
closeDelay: 0
});
await importSubscriptions(importedSubs);
toaster.removeToast('import-to-materialious');
addToast({
data: {
text: $_('layout.backendEngine.importingToMaterialiousFinished')
}
});
}
async function exportMaterialiousSubs() {
const subs = await getSubscriptions();
addToast({
data: {
text: $_('layout.backendEngine.exportingToInvidious')
},
id: 'export-to-invidious',
closeDelay: 0
});
const subPromises: Promise<void>[] = [];
for (const sub of subs) {
subPromises.push(postSubscribeInvidious(sub.authorId));
}
await Promise.all(subPromises);
toaster.removeToast('export-to-invidious');
addToast({
data: {
text: $_('layout.backendEngine.exportingToInvidiousFinished')
}
});
}
</script>
<h3>{$_('pages.subscriptions')}</h3>
<div class="space"></div>
<div>
<button class="surface-container-highest">
<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;
addToast({
data: {
text: $_('layout.export.importing')
},
id: 'importing-toast',
closeDelay: 0
});
await importSubscriptionsFromFile(files[0]);
toaster.removeToast('importing-toast');
addToast({
data: {
text: $_('layout.export.importingCompleted')
}
});
}}
accept=".json,.opml,.csv"
type="file"
/>
</div>
<p><i>info</i> {$_('layout.export.importSubSupported')}</p>
<div class="space"></div>
<div>
<button class="surface-container-highest">
<i>file_export</i>
<span>{$_('layout.export.exportSub')}</span>
</button>
<menu>
<li role="presentation" onclick={() => exportSubscriptionsAsFile('InvidiousJSON')}>
Invidious JSON
</li>
<li role="presentation" onclick={() => exportSubscriptionsAsFile('OPML')}>
FreeTube/NewPipe/YouTube OPML
</li>
</menu>
</div>
{#if $invidiousAuthStore && $invidiousInstanceStore && $backendInUseStore === 'yt'}
<div class="space"></div>
<h6>{$_('layout.backendEngine.importExport')}</h6>
<div class="space"></div>
<button onclick={exportMaterialiousSubs} class="surface-container-highest">
<i>upload</i>
<div>{$_('layout.backendEngine.exportToInvidious')}</div>
</button>
<div class="space"></div>
<button onclick={importInvidiousSubs} class="surface-container-highest">
<i>download</i>
<div>{$_('layout.backendEngine.importToMaterialious')}</div>
</button>
{/if}
<h3>{$_('layout.settings')}</h3>
<div class="space"></div>
<button
class="no-margin surface-container-highest"
onclick={async () => {
await Clipboard.write({ string: settingsToJson() });
addToast({
data: {
text: $_('player.share.copiedSuccess')
}
});
}}
>
<i>content_copy</i>
<span>{$_('layout.export.exportToClipboard')}</span>
</button>
<div class="space"></div>
<button
class="no-margin surface-container-highest"
onclick={async () => {
downloadStringAsFile(settingsToJson(), 'materialious-settings.json');
}}
>
<i>file_export</i>
<span>{$_('layout.export.exportToFile')}</span>
</button>
<div class="space"></div>
<div>
<button class="surface-container-highest">
<i>attach_file</i>
<span>{$_('layout.export.importFromFile')}</span>
</button>
<input
onchange={async (event: Event) => {
const files = (event.target as HTMLInputElement).files;
if (files?.length === 0 || !files) return;
await loadSettingsFromFile(files[0]);
}}
accept=".json"
type="file"
/>
</div>
@@ -1,8 +1,7 @@
<script lang="ts">
import { bookmarkletSaveToUrl, settingsToJson } from '$lib/externalSettings/index';
import { bookmarkletSaveToUrl } from '$lib/externalSettings/index';
import { letterCase, titleCases } from '$lib/letterCasing';
import { setAmoledTheme } from '$lib/theme';
import { Clipboard } from '@capacitor/clipboard';
import { Capacitor } from '@capacitor/core';
import ui from 'beercss';
import { iso31661 } from 'iso-3166';
@@ -34,7 +33,6 @@
themeColorStore,
watchHistoryEnabledStore
} from '../../store';
import { addToast } from '../Toast.svelte';
import { tick } from 'svelte';
import { isOwnBackend } from '$lib/shared';
@@ -168,12 +166,12 @@
</form>
{#if isOwnBackend()?.internalAuth && $invidiousInstanceStore}
{#if !$invidiousAuthStore}
<button onclick={goToInvidiousLogin}>
<button class="surface-container-highest" onclick={goToInvidiousLogin}>
<i>link</i>
<span>{$_('linkInvidious')}</span>
</button>
{:else}
<button onclick={invidiousLogout}>
<button class="surface-container-highest" onclick={invidiousLogout}>
<i>link_off</i>
<span>{$_('unlinkInvidious')}</span>
</button>
@@ -204,7 +202,7 @@
{/if}
{/if}
<button onclick={toggleDarkMode} class="no-margin">
<button onclick={toggleDarkMode} class="no-margin surface-container-highest">
{#if !$darkModeStore}
<i>dark_mode</i>
<span>{$_('layout.theme.darkMode')}</span>
@@ -213,7 +211,7 @@
<span>{$_('layout.theme.lightMode')}</span>
{/if}
</button>
<button onclick={() => (colorPickerOpen = !colorPickerOpen)}>
<button class="surface-container-highest" onclick={() => (colorPickerOpen = !colorPickerOpen)}>
<i>palette</i>
<span>{$_('layout.theme.color')}</span>
</button>
@@ -457,7 +455,7 @@
<h6>{$_('layout.bookmarklet')}</h6>
<div class="space"></div>
<button
class="no-margin"
class="no-margin surface-container-highest"
onclick={async () => {
await shareURL(bookmarkletSaveToUrl());
}}
@@ -466,26 +464,6 @@
<span>{$_('copyUrl')}</span>
</button>
</div>
<div class="space"></div>
<div class="settings">
<h6>{$_('layout.exportToJson')}</h6>
<div class="space"></div>
<button
class="no-margin"
onclick={async () => {
await Clipboard.write({ string: settingsToJson() });
addToast({
data: {
text: get(_)('player.share.copiedSuccess')
}
});
}}
>
<i>content_copy</i>
<span>{$_('copy')}</span>
</button>
</div>
{/if}
<style>
@@ -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'),
@@ -80,6 +80,21 @@ function setStores(toSet: Record<string, unknown>, overwriteExisting = false) {
}
}
export async function loadSettingsFromFile(file: File) {
const fileContents = await file.text();
let fileJson: Record<any, any> | undefined;
try {
fileJson = JSON.parse(fileContents);
} catch {
// Handled outside of catch.
}
if (!fileJson) return;
setStores(fileJson, true);
}
export function loadSettingsFromEnv() {
const defaultSettings = getPublicEnv('DEFAULT_SETTINGS');
+13 -2
View File
@@ -4,7 +4,7 @@
"popularPageDisabled": "Popular page has been disabled by Admins",
"premium": "Premium YouTube content can't be watched on Materialious.",
"copyUrl": "Copy URL",
"copy": "Copy JSON",
"copy": "Copy to clipboard",
"loadMore": "Load more",
"views": "views",
"login": "Login",
@@ -210,7 +210,7 @@
"cooldown": "Re-fetch channel hour cooldown",
"concurrent": "Max amount of channels to parse before feed return",
"fallbacks": "Fallbacks",
"importExport": "Import/Export subscriptions",
"importExport": "Automatic export/import",
"exportToInvidious": "Export to Invidious from Materialious",
"importToMaterialious": "Import from Invidious to Materialious",
"importingToMaterialious": "Importing to Materialious began",
@@ -271,6 +271,17 @@
"addConditional": "Add conditional",
"addFilter": "Add filter"
},
"export": {
"title": "Export/Import",
"importSub": "Import subscriptions",
"importSubSupported": "YouTube CSV/OPML/JSON, Invidious OPML/JSON, Freetube database & NewPipe JSON supported",
"importing": "Importing subscriptions (this will take a moment)",
"importingCompleted": "Subscriptions successfully imported",
"exportSub": "Export subscriptions",
"exportToClipboard": "Export to clipboard",
"exportToFile": "Export to file",
"importFromFile": "Import from file"
},
"deArrow": {
"title": "DeArrow",
"thumbnailInstanceUrl": "Thumbnail instance URL",
+227
View File
@@ -0,0 +1,227 @@
import z from 'zod';
import type { Subscription } from './api/model';
import { getChannel, getSubscriptions, postSubscribe } from './api';
import Papa from 'papaparse';
import { downloadStringAsFile } from './misc';
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()
})
)
});
const zNewPipeSubs = z.object({
subscriptions: z.array(
z.object({
service_id: z.number(),
url: z.url(),
name: z.string()
})
)
});
const zYouTubeSubs = z.array(
z.object({
snippet: z.object({
channelId: z.string(),
title: z.string()
})
})
);
const zYouTubeCsvSubs = z.array(
z.object({
'Channel ID': z.string(),
'Channel title': 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 (file.name.endsWith('.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 if (file.name.endsWith('.json')) {
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);
const newPipeSubs = zNewPipeSubs.safeParse(fileJson);
const youtubeSubs = zYouTubeSubs.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
});
}
} else if (newPipeSubs.success) {
for (const sub of newPipeSubs.data.subscriptions) {
const authorId = sub.url.split('/')[4];
if (typeof authorId !== 'string') continue;
subsToImport.push({
author: sub.name,
authorId
});
}
} else if (youtubeSubs.success) {
for (const sub of youtubeSubs.data) {
subsToImport.push({
author: sub.snippet.title,
authorId: sub.snippet.channelId
});
}
}
}
} else if (file.name.endsWith('.csv')) {
const csv = Papa.parse(fileContents, { header: true, skipEmptyLines: true });
if (csv.data.length > 0) {
const youtubeSubs = zYouTubeCsvSubs.safeParse(csv.data);
if (youtubeSubs.success) {
for (const sub of youtubeSubs.data) {
subsToImport.push({
author: sub['Channel title'],
authorId: sub['Channel ID']
});
}
}
}
}
if (subsToImport.length > 0) {
await importSubscriptions(subsToImport);
return;
}
throw new Error('Unable to determine imported file type');
}
export async function exportSubscriptionsAsFile(exportType: 'InvidiousJSON' | 'OPML') {
const subscriptions = await getSubscriptions();
let data: string | undefined;
if (exportType === 'InvidiousJSON') {
const subIds = [];
for (const sub of subscriptions) {
subIds.push(sub.authorId);
}
data = JSON.stringify({
subscriptions: subIds
});
} else {
const xmlDoc = document.implementation.createDocument('', '', null);
const opml = xmlDoc.createElement('opml');
opml.setAttribute('version', '1.1');
const body = xmlDoc.createElement('body');
const mainOutline = xmlDoc.createElement('outline');
mainOutline.setAttribute('text', 'YouTube Subscriptions');
mainOutline.setAttribute('title', 'YouTube Subscriptions');
body.appendChild(mainOutline);
for (const sub of subscriptions) {
const outline = xmlDoc.createElement('outline');
outline.setAttribute('text', sub.author);
outline.setAttribute('title', sub.author);
outline.setAttribute('type', 'rss');
outline.setAttribute(
'xmlUrl',
`https://www.youtube.com/feeds/videos.xml?channel_id=${sub.authorId}`
);
body.appendChild(outline);
}
opml.appendChild(body);
xmlDoc.appendChild(opml);
const serializer = new XMLSerializer();
data = serializer.serializeToString(xmlDoc);
}
if (data) {
downloadStringAsFile(
data,
`materialious-export-${new Date().toDateString().replaceAll(' ', '')}.${exportType === 'InvidiousJSON' ? 'json' : 'opml'}`
);
}
}
+14
View File
@@ -118,3 +118,17 @@ export function isUnrestrictedPlatform(): boolean {
export function isYTBackend(): boolean {
return get(backendInUseStore) === 'yt' && isUnrestrictedPlatform();
}
export function downloadStringAsFile(content: string, filename: string) {
const blob = new Blob([content]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+2
View File
@@ -51,6 +51,8 @@ export class User {
}
async addSubscription(subscription: Omit<ChannelSubscriptionModel, 'userId'>) {
if (await this.amSubscribed(subscription.id)) return;
await getSequelize().ChannelSubscriptionTable.create({
...subscription,
UserId: this.id
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.16.11"
LATEST_VERSION = "1.16.12"
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")