Merge pull request #1530 from Materialious/update/1.16.12
Update/1.16.12
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+20
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user