@@ -0,0 +1,42 @@
|
||||
name: Build full web
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "./fastline/**"
|
||||
- "./materialious/src/lib/i18n/**"
|
||||
|
||||
jobs:
|
||||
web-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Extract version from package.json
|
||||
id: extract_version
|
||||
run: |
|
||||
VERSION=$(jq -r .version ./materialious/package.json)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./materialious
|
||||
file: FullDockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
wardpearce/materialious-full:latest
|
||||
wardpearce/materialious-full:${{ steps.extract_version.outputs.version }}
|
||||
@@ -19,7 +19,7 @@
|
||||
- Use in local mode without relying on a Invidious instance.
|
||||
- [Invidious companion support.](./docs/DOCKER.md#invidious-companion-support)
|
||||
- [Invidious API extended integration.](https://github.com/Materialious/api-extended)
|
||||
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop & Android.
|
||||
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos.
|
||||
- Android TV support
|
||||
- Support for disabling certificate validation for homelab users.
|
||||
- Sync your watch progress between Invidious sessions.
|
||||
@@ -41,14 +41,12 @@
|
||||
- PWA support.
|
||||
- YT path redirects (So your redirect plugins should still work!)
|
||||
|
||||
# Support table
|
||||
| | Dash | HLS | Local video fallback | API-Extended | Dearrow | RYD | Watch Parties |
|
||||
|---------|------|-----|----------------------|--------------|---------|-----|----------------|
|
||||
| Web | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Desktop | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| Android | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
# Docker deployment
|
||||
[Please read the guide here](./docs/DOCKER-FULL.md)
|
||||
|
||||
# Legacy Docker deployment, Invidious only.
|
||||
This version of Materialious is still **fully** supported, but is purely just a Invidious frontend without any fancy bells and whistles, this version is harder to deploy...
|
||||
|
||||
# Deploying as a website via docker
|
||||
[Please read the guide here](./docs/DOCKER.md)
|
||||
|
||||
# Installing as a app
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
services:
|
||||
materialious:
|
||||
image: wardpearce/materialious
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3001:80
|
||||
environment:
|
||||
VITE_DEFAULT_INVIDIOUS_INSTANCE: "https://invidious.materialio.us"
|
||||
VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE: "https://returnyoutubedislikeapi.com"
|
||||
VITE_DEFAULT_SPONSERBLOCK_INSTANCE: "https://sponsor.ajay.app"
|
||||
VITE_DEFAULT_DEARROW_INSTANCE: "https://sponsor.ajay.app"
|
||||
VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE: "https://dearrow-thumb.ajay.app"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
## Step 1: Docker
|
||||
|
||||
This Guide is for the docker image `wardpearce/materialious-full` **NOT** `wardpearce/materialious`
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
materialious:
|
||||
image: wardpearce/materialious-full:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
# Secure secret for signing cookies
|
||||
# Not required if VITE_INTERNAL_AUTH is false
|
||||
COOKIE_SECRET: ""
|
||||
|
||||
# Database connectiong URI
|
||||
# Not required if VITE_INTERNAL_AUTH is false
|
||||
# postgresql, mysql2, mariadb & sqlite supported
|
||||
# guide here for URL structure https://docs.preset.io/docs/uri-connection-strings
|
||||
DATABASE_CONNECTION_URI: "sqlite://materialious.db"
|
||||
|
||||
# Use Materialious account system.
|
||||
PUBLIC_INTERNAL_AUTH: "true"
|
||||
|
||||
# If Auth is required to use this instance.
|
||||
# Should be left as true, otherwise anyone can use Materialious proxy.
|
||||
PUBLIC_REQUIRE_AUTH: "true"
|
||||
|
||||
# Allow anyone to register
|
||||
PUBLIC_REGISTRATION_ALLOWED: "false"
|
||||
|
||||
# Allow any domain in proxy
|
||||
# This shouldn't be used unless you KNOW what your doing
|
||||
# requires VITE_REGISTRATION_ALLOWED to be false
|
||||
# VITE_REQUIRE_AUTH to be true
|
||||
# VITE_INTERNAL_AUTH to be true
|
||||
# to take effect.
|
||||
PUBLIC_DANGEROUS_ALLOW_ANY_PROXY: "false"
|
||||
|
||||
# Optionally set a default Invidious instance
|
||||
# This will also whitelist this instance in the proxy.
|
||||
PUBLIC_DEFAULT_INVIDIOUS_INSTANCE: ""
|
||||
|
||||
# URL TO RYD (Return YouTube Dislike / https://github.com/Anarios/return-youtube-dislike)
|
||||
# Leave blank to disable completely.
|
||||
# This will also whitelist this instance in the proxy.
|
||||
PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE: "https://returnyoutubedislikeapi.com"
|
||||
|
||||
# URL to Sponsorblock
|
||||
# Leave blank to completely disable sponsorblock.
|
||||
# This will also whitelist this instance in the proxy.
|
||||
PUBLIC_DEFAULT_SPONSERBLOCK_INSTANCE: "https://sponsor.ajay.app"
|
||||
|
||||
# URL to DeArrow
|
||||
# This will also whitelist this instance in the proxy.
|
||||
PUBLIC_DEFAULT_DEARROW_INSTANCE: "https://sponsor.ajay.app"
|
||||
|
||||
# URL to DeArrow thumbnail instance
|
||||
# This will also whitelist this instance in the proxy.
|
||||
PUBLIC_DEFAULT_DEARROW_THUMBNAIL_INSTANCE: "https://dearrow-thumb.ajay.app"
|
||||
|
||||
# Look at "Overwriting Materialious defaults" for all the accepted values.
|
||||
# This will also whitelist this instance in the proxy.
|
||||
PUBLIC_DEFAULT_SETTINGS: '{"themeColor": "#2596be","region": "US"}'
|
||||
|
||||
volumes:
|
||||
- materialious-data:/materialious.db
|
||||
|
||||
volumes:
|
||||
materialious-data:
|
||||
```
|
||||
|
||||
### Overwriting Materialious defaults
|
||||
Materialious allows you to overwrite the default values using `VITE_DEFAULT_SETTINGS`, see [SETTINGS](./SETTINGS.md) for more details.
|
||||
|
||||
**Please note:** These overwrites only apply on 1st load & won't replace existing configuration stored in browser local storage.
|
||||
|
||||
## Step 2 (Optional, but recommended): Self-host RYD-Proxy
|
||||
#### With TOR (Recommended)
|
||||
```yml
|
||||
tor-proxy:
|
||||
image: 1337kavin/alpine-tor:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- tors=15
|
||||
|
||||
ryd-proxy:
|
||||
image: 1337kavin/ryd-proxy:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- tor-proxy
|
||||
environment:
|
||||
- PROXY=socks5://tor-proxy:5566
|
||||
ports:
|
||||
- 3003:3000
|
||||
```
|
||||
#### Without TOR
|
||||
```yml
|
||||
ryd-proxy:
|
||||
image: 1337kavin/ryd-proxy:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3003:3000
|
||||
```
|
||||
|
||||
Modify/add `VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE` for Materialious to be the reverse proxied URL of RYD-Proxy.
|
||||
|
||||
## Step 3 (Optional, but recommended): Self-host Invidious API extended
|
||||
```yaml
|
||||
services:
|
||||
api_extended:
|
||||
image: wardpearce/invidious_api_extended:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3004:80
|
||||
environment:
|
||||
api_extended_postgre: '{"host": "invidious-db", "port": 5432, "database": "invidious", "user": "kemal", "password": "kemal"}'
|
||||
api_extended_allowed_origins: '["https://materialious.example.com"]'
|
||||
api_extended_debug: false
|
||||
|
||||
# No trailing backslashes!
|
||||
api_extended_invidious_instance: "https://invidious.example.com"
|
||||
api_extended_production_instance: "https://syncious.example.com"
|
||||
```
|
||||
|
||||
Add these additional environment variables to Materialious.
|
||||
```yaml
|
||||
VITE_DEFAULT_API_EXTENDED_INSTANCE: "https://syncious.example.com"
|
||||
```
|
||||
|
||||
## Step 4 (Optional): Self-host PeerJS
|
||||
[Read the official guide.](https://github.com/peers/peerjs-server?tab=readme-ov-file#docker)
|
||||
|
||||
Add these additional environment variables to Materialious.
|
||||
```yaml
|
||||
# Will differ depending on how you self-host peerjs.
|
||||
VITE_DEFAULT_PEERJS_HOST: "peerjs.example.com"
|
||||
VITE_DEFAULT_PEERJS_PATH: "/"
|
||||
VITE_DEFAULT_PEERJS_PORT: 443
|
||||
```
|
||||
@@ -1,5 +1,7 @@
|
||||
# Setup
|
||||
|
||||
This Guide is for the docker image `wardpearce/materialious` **NOT** `wardpearce/materialious-full`
|
||||
|
||||
### CORS
|
||||
To assure browsers they are allowed to access your Materialious instance
|
||||
despite their default [same-origin policy][wp-sop], the instance's webserver
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
README.md
|
||||
.npmrc
|
||||
.prettierrc
|
||||
.eslintrc.cjs
|
||||
.graphqlrc
|
||||
.editorconfig
|
||||
.svelte-kit
|
||||
.vscode
|
||||
node_modules
|
||||
build
|
||||
package
|
||||
**/.env
|
||||
@@ -1,3 +1,5 @@
|
||||
# This is the docker file for legacy deployment wardpearce/materialious
|
||||
|
||||
FROM node:latest AS builder
|
||||
|
||||
# Set the working directory
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm ci
|
||||
|
||||
# Tell SvelteKit to use node adapter.
|
||||
ENV PUBLIC_BUILD_WITH_BACKEND="true"
|
||||
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/build build/
|
||||
COPY --from=builder /app/node_modules node_modules/
|
||||
|
||||
COPY package.json .
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Tell the front end backend is in use.
|
||||
ENV PUBLIC_BUILD_WITH_BACKEND="true"
|
||||
|
||||
CMD ["node", "build"]
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 208
|
||||
versionName "1.14.4"
|
||||
versionCode 209
|
||||
versionName "1.15.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -73,7 +73,11 @@
|
||||
|
||||
|
||||
|
||||
<release version="1.14.4" date="2026-2-13">
|
||||
|
||||
<release version="1.15.0" date="2026-2-13">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.15.0</url>
|
||||
</release>
|
||||
<release version="1.14.4" date="2026-2-13">
|
||||
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.4</url>
|
||||
</release>
|
||||
<release version="1.14.3" date="2026-2-13">
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.14.4",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Materialious",
|
||||
"version": "1.14.4",
|
||||
"version": "1.15.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.14.4",
|
||||
"version": "1.15.0",
|
||||
"description": "Modern material design for YouTube and Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
|
||||
@@ -85,8 +85,7 @@ ipcMain.handle(
|
||||
{
|
||||
url: youtubeUrl,
|
||||
referrer: youtubeUrl,
|
||||
origin: youtubeUrl,
|
||||
USER_AGENT
|
||||
userAgent: USER_AGENT
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Generated
+2327
-157
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.14.4",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run patch:github && vite dev",
|
||||
@@ -19,12 +19,15 @@
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-node": "^5.5.3",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/cookie-signature": "^1.1.2",
|
||||
"@types/event-source-polyfill": "^1.0.5",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/human-number": "^1.0.2",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -34,7 +37,6 @@
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"svelte": "^5.47.0",
|
||||
"svelte-awesome-color-picker": "^4.1.1",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -55,11 +57,15 @@
|
||||
"@capacitor/share": "^8.0.0",
|
||||
"@macfja/serializer": "^1.1.4",
|
||||
"@macfja/svelte-persistent-store": "^2.4.2",
|
||||
"altcha": "^2.3.0",
|
||||
"altcha-lib": "^1.4.1",
|
||||
"beercss": "^4.0.2",
|
||||
"bgutils-js": "^3.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-music-controls-plugin": "^6.1.0",
|
||||
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
|
||||
"comlink": "^4.4.2",
|
||||
"cookie-signature": "^1.2.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"dexie": "^4.2.1",
|
||||
"fuse.js": "^7.0.0",
|
||||
@@ -69,15 +75,25 @@
|
||||
"i18next": "^25.7.2",
|
||||
"iso-3166": "^4.4.0",
|
||||
"iso-639-1": "^3.1.5",
|
||||
"jsdom": "^28.0.0",
|
||||
"libsodium-wrappers-sumo": "^0.8.2",
|
||||
"mariadb": "^3.4.5",
|
||||
"material-dynamic-colors": "^1.1.1",
|
||||
"media-captions": "^1.0.4",
|
||||
"melt": "^0.44.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
"mysql2": "^3.17.1",
|
||||
"peerjs": "^1.5.5",
|
||||
"pg": "^8.18.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"psl": "^1.15.0",
|
||||
"sequelize": "^6.37.7",
|
||||
"shaka-player": "^4.16.14",
|
||||
"sponsorblock-api": "^0.2.4",
|
||||
"sqlite3": "^5.1.7",
|
||||
"svelte-awesome-color-picker": "^4.1.1",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"youtubei.js": "^16.0.1",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
-1
@@ -3,7 +3,10 @@ import type { IGetChallengeResponse } from 'youtubei.js';
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
userId: string;
|
||||
captchaKey: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import { getSequelize } from '$lib/server/database';
|
||||
import { unsign } from 'cookie-signature';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
let captchaKey = '';
|
||||
sodium.ready.then(() => {
|
||||
captchaKey = sodium.to_base64(sodium.randombytes_buf(32));
|
||||
});
|
||||
|
||||
let sequelizeAuthenticated = false;
|
||||
export async function handle({ event, resolve }) {
|
||||
if (!isOwnBackend()?.internalAuth) {
|
||||
return await resolve(event);
|
||||
}
|
||||
|
||||
event.locals.captchaKey = captchaKey;
|
||||
|
||||
const sequelize = getSequelize();
|
||||
if (!sequelizeAuthenticated) {
|
||||
await sequelize.sequelize.sync();
|
||||
await sequelize.sequelize.authenticate();
|
||||
sequelizeAuthenticated = true;
|
||||
}
|
||||
|
||||
if (!env.COOKIE_SECRET) {
|
||||
throw new Error('Cookie secret must be set');
|
||||
}
|
||||
|
||||
if (env.COOKIE_SECRET.length < 16) {
|
||||
throw new Error('COOKIE_SECRET must be at least 16 characters long');
|
||||
}
|
||||
|
||||
const signedUserId = event.cookies.get('userid');
|
||||
if (signedUserId) {
|
||||
const userId = unsign(signedUserId, env.COOKIE_SECRET);
|
||||
if (userId) {
|
||||
event.locals.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
return await resolve(event);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
import { rawMasterKeyStore } from '../store';
|
||||
import { get } from 'svelte/store';
|
||||
import { parseChannelRSS } from './youtubejs/subscriptions';
|
||||
import { getChannelYTjs } from './youtubejs/channel';
|
||||
import type { ChannelSubscriptions } from '$lib/dexie';
|
||||
|
||||
async function getInternalAuthorId(authorId: string, rawKey: Uint8Array): Promise<string> {
|
||||
await sodium.ready;
|
||||
return sodium.to_base64(
|
||||
sodium.crypto_generichash(sodium.crypto_generichash_BYTES, authorId, rawKey)
|
||||
);
|
||||
}
|
||||
|
||||
async function getRawKey(): Promise<Uint8Array | undefined> {
|
||||
const rawMasterKey = get(rawMasterKeyStore);
|
||||
if (!rawMasterKey) return;
|
||||
|
||||
await sodium.ready;
|
||||
|
||||
return sodium.from_base64(rawMasterKey);
|
||||
}
|
||||
|
||||
export async function getSubscriptionsBackend(): Promise<ChannelSubscriptions[]> {
|
||||
const resp = await fetch(`/api/user/subscriptions`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!resp.ok) return [];
|
||||
|
||||
const subscriptions: ChannelSubscriptions[] = [];
|
||||
|
||||
const respJson = await resp.json();
|
||||
|
||||
for (const sub of respJson.subscriptions) {
|
||||
subscriptions.push({
|
||||
channelName: (await decryptWithMasterKey(sub.channelNameNonce, sub.channelNameCipher)) ?? '',
|
||||
channelId: (await decryptWithMasterKey(sub.channelIdNonce, sub.channelIdCipher)) ?? '',
|
||||
lastRSSFetch: new Date(sub.lastRSSFetch)
|
||||
});
|
||||
}
|
||||
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
export async function updateRSSLastUpdated(authorId: string) {
|
||||
const rawKey = await getRawKey();
|
||||
if (!rawKey) return false;
|
||||
|
||||
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
|
||||
|
||||
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
export async function amSubscribedBackend(authorId: string): Promise<boolean> {
|
||||
const rawKey = await getRawKey();
|
||||
if (!rawKey) return false;
|
||||
|
||||
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
|
||||
|
||||
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
if (!resp.ok) return false;
|
||||
|
||||
const respJson = await resp.json();
|
||||
|
||||
return respJson.amSubscribed;
|
||||
}
|
||||
|
||||
export async function deleteUnsubscribeBackend(authorId: string) {
|
||||
const rawKey = await getRawKey();
|
||||
if (!rawKey) return false;
|
||||
|
||||
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
|
||||
|
||||
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSubscribeBackend(authorId: string) {
|
||||
const rawKey = await getRawKey();
|
||||
if (!rawKey) return;
|
||||
|
||||
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
|
||||
|
||||
const channel = await getChannelYTjs(authorId);
|
||||
|
||||
const channelId = await encryptWithMasterKey(authorId);
|
||||
const channelName = await encryptWithMasterKey(channel.author);
|
||||
|
||||
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelIdCipher: channelId?.cipher,
|
||||
channelIdNonce: channelId?.nonce,
|
||||
channelNameCipher: channelName?.cipher,
|
||||
channelNameNonce: channelName?.nonce
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (resp.ok) parseChannelRSS(authorId);
|
||||
}
|
||||
|
||||
export type DerivePassword = (rawPassword: string, passwordSalt: Uint8Array) => Promise<Uint8Array>;
|
||||
|
||||
export async function createUserBackend(
|
||||
username: string,
|
||||
rawPassword: string,
|
||||
captchaPayload: string,
|
||||
derivePassword: DerivePassword
|
||||
): Promise<boolean> {
|
||||
await sodium.ready;
|
||||
|
||||
const passwordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
||||
|
||||
const loginHash = await derivePassword(rawPassword, passwordSalt);
|
||||
|
||||
const decryptionKeySalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
||||
const rawDecryptionKey = sodium.crypto_pwhash(
|
||||
32,
|
||||
rawPassword,
|
||||
decryptionKeySalt,
|
||||
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_ALG_DEFAULT
|
||||
);
|
||||
|
||||
const rawDecryptionMasterKey = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
|
||||
const decryptionMasterKeyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
|
||||
const masterKeyCipher = sodium.crypto_secretbox_easy(
|
||||
rawDecryptionMasterKey,
|
||||
decryptionMasterKeyNonce,
|
||||
rawDecryptionKey
|
||||
);
|
||||
|
||||
const userCreateResp = await fetch('/api/user/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: {
|
||||
hash: sodium.to_base64(loginHash),
|
||||
salt: sodium.to_base64(passwordSalt)
|
||||
},
|
||||
decryptionKeySalt: sodium.to_base64(decryptionKeySalt),
|
||||
masterKey: {
|
||||
cipher: sodium.to_base64(masterKeyCipher),
|
||||
nonce: sodium.to_base64(decryptionMasterKeyNonce)
|
||||
},
|
||||
captchaPayload
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!userCreateResp.ok) return false;
|
||||
|
||||
rawMasterKeyStore.set(sodium.to_base64(rawDecryptionMasterKey));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loginUserBackend(
|
||||
username: string,
|
||||
rawPassword: string,
|
||||
captchaPayload: string,
|
||||
derivePassword: DerivePassword
|
||||
): Promise<boolean> {
|
||||
await sodium.ready;
|
||||
|
||||
const passwordSaltsResp = await fetch(`/api/user/${username}/public`);
|
||||
if (!passwordSaltsResp.ok) return false;
|
||||
|
||||
const passwordSalts = await passwordSaltsResp.json();
|
||||
|
||||
const loginHash = await derivePassword(
|
||||
rawPassword,
|
||||
sodium.from_base64(passwordSalts.passwordSalt)
|
||||
);
|
||||
|
||||
const loginResp = await fetch('/api/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
passwordHash: sodium.to_base64(loginHash),
|
||||
captchaPayload
|
||||
}),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!loginResp.ok) return false;
|
||||
|
||||
const loginJson = await loginResp.json();
|
||||
|
||||
const rawDecryptionKey = sodium.crypto_pwhash(
|
||||
sodium.crypto_secretbox_KEYBYTES,
|
||||
rawPassword,
|
||||
sodium.from_base64(passwordSalts.decryptionKeySalt),
|
||||
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
sodium.crypto_pwhash_ALG_DEFAULT
|
||||
);
|
||||
|
||||
const rawDecryptionMasterKey = sodium.crypto_secretbox_open_easy(
|
||||
sodium.from_base64(loginJson.masterKeyCipher),
|
||||
sodium.from_base64(loginJson.masterKeyNonce),
|
||||
rawDecryptionKey
|
||||
);
|
||||
|
||||
rawMasterKeyStore.set(sodium.to_base64(rawDecryptionMasterKey));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function encryptWithMasterKey(
|
||||
text: string
|
||||
): Promise<{ nonce: string; cipher: string } | undefined> {
|
||||
await sodium.ready;
|
||||
const rawKey = await getRawKey();
|
||||
if (!rawKey) return;
|
||||
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const cipher = sodium.crypto_secretbox_easy(new TextEncoder().encode(text), nonce, rawKey);
|
||||
|
||||
return {
|
||||
nonce: sodium.to_base64(nonce),
|
||||
cipher: sodium.to_base64(cipher)
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptWithMasterKey(nonce: string, cipher: string): Promise<string | undefined> {
|
||||
await sodium.ready;
|
||||
const rawKey = await getRawKey();
|
||||
if (!rawKey) return;
|
||||
|
||||
return new TextDecoder().decode(
|
||||
sodium.crypto_secretbox_open_easy(sodium.from_base64(cipher), sodium.from_base64(nonce), rawKey)
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { getVideoYTjs } from '$lib/api/youtubejs/video';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
deArrowInstanceStore,
|
||||
deArrowThumbnailInstanceStore,
|
||||
instanceStore,
|
||||
invidiousInstanceStore,
|
||||
interfaceRegionStore,
|
||||
playerYouTubeJsAlways,
|
||||
playerYouTubeJsFallback,
|
||||
rawMasterKeyStore,
|
||||
returnYTDislikesInstanceStore,
|
||||
synciousInstanceStore
|
||||
} from '../store';
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
} from './model';
|
||||
import { commentsSetDefaults, searchSetDefaults, useEngineFallback } from './misc';
|
||||
import { getSearchYTjs } from './youtubejs/search';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { isUnrestrictedPlatform, isYTBackend } from '$lib/misc';
|
||||
import { getSearchSuggestionsYTjs } from './youtubejs/searchSuggestions';
|
||||
import { getResolveUrlYTjs } from './youtubejs/misc';
|
||||
import { getCommentsYTjs } from './youtubejs/comments';
|
||||
@@ -46,9 +46,16 @@ import {
|
||||
postSubscribeYTjs
|
||||
} from './youtubejs/subscriptions';
|
||||
import { getPlaylistYTjs } from './youtubejs/playlist';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import {
|
||||
amSubscribedBackend,
|
||||
deleteUnsubscribeBackend,
|
||||
getSubscriptionsBackend,
|
||||
postSubscribeBackend
|
||||
} from './backend';
|
||||
|
||||
export function buildPath(path: string): URL {
|
||||
return new URL(`${get(instanceStore)}/api/v1/${path}`);
|
||||
return new URL(`${get(invidiousInstanceStore)}/api/v1/${path}`);
|
||||
}
|
||||
|
||||
export function setRegion(url: URL): URL {
|
||||
@@ -77,7 +84,7 @@ export async function fetchErrorHandle(response: Response): Promise<Response> {
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(): { headers: Record<string, string> } {
|
||||
const authToken = get(authStore)?.token ?? '';
|
||||
const authToken = get(invidiousAuthStore)?.token ?? '';
|
||||
if (authToken.startsWith('SID=')) {
|
||||
return { headers: { __sid_auth: authToken } };
|
||||
} else {
|
||||
@@ -110,7 +117,7 @@ export async function getVideo(
|
||||
fetchOptions?: RequestInit
|
||||
): Promise<VideoPlay> {
|
||||
if (
|
||||
(get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) ||
|
||||
(get(playerYouTubeJsAlways) && isUnrestrictedPlatform()) ||
|
||||
isYTBackend() ||
|
||||
useEngineFallback('Video')
|
||||
) {
|
||||
@@ -119,7 +126,7 @@ export async function getVideo(
|
||||
|
||||
const resp = await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`)), fetchOptions);
|
||||
|
||||
if (!resp.ok && get(playerYouTubeJsFallback) && Capacitor.isNativePlatform()) {
|
||||
if (!resp.ok && get(playerYouTubeJsFallback) && isUnrestrictedPlatform()) {
|
||||
return await getVideoYTjs(videoId);
|
||||
} else {
|
||||
await fetchErrorHandle(resp);
|
||||
@@ -174,6 +181,10 @@ export async function getChannelContent(
|
||||
): Promise<ChannelContent> {
|
||||
if (typeof options.type === 'undefined') options.type = 'videos';
|
||||
|
||||
if (isYTBackend() || useEngineFallback('ChannelContent')) {
|
||||
return await getChannelContentYTjs(channelId, options);
|
||||
}
|
||||
|
||||
const url = buildPath(`channels/${channelId}/${options.type}`);
|
||||
|
||||
if (typeof options.continuation !== 'undefined')
|
||||
@@ -181,10 +192,6 @@ export async function getChannelContent(
|
||||
|
||||
if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy);
|
||||
|
||||
if (isYTBackend() || useEngineFallback('ChannelContent')) {
|
||||
return await getChannelContentYTjs(channelId, options);
|
||||
}
|
||||
|
||||
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
|
||||
return await resp.json();
|
||||
}
|
||||
@@ -279,6 +286,15 @@ export async function getSubscriptions(
|
||||
bypassYTBackend: boolean = false
|
||||
): Promise<Subscription[]> {
|
||||
if (isYTBackend() && !bypassYTBackend) {
|
||||
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
|
||||
return (await getSubscriptionsBackend()).map((sub) => {
|
||||
return {
|
||||
author: sub.channelName,
|
||||
authorId: sub.channelId
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return getSubscriptionsYTjs();
|
||||
}
|
||||
const resp = await fetchErrorHandle(
|
||||
@@ -292,10 +308,14 @@ export async function amSubscribed(
|
||||
fetchOptions: RequestInit = {}
|
||||
): Promise<boolean> {
|
||||
if (isYTBackend()) {
|
||||
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
|
||||
return amSubscribedBackend(authorId);
|
||||
}
|
||||
|
||||
return amSubscribedYTjs(authorId);
|
||||
}
|
||||
|
||||
if (!get(authStore)) return false;
|
||||
if (!get(invidiousAuthStore)) return false;
|
||||
|
||||
try {
|
||||
const subscriptions = (await getSubscriptions(fetchOptions)).filter(
|
||||
@@ -313,6 +333,10 @@ export async function postSubscribe(
|
||||
bypassYTBackend: boolean = false
|
||||
) {
|
||||
if (isYTBackend() && !bypassYTBackend) {
|
||||
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
|
||||
return postSubscribeBackend(authorId);
|
||||
}
|
||||
|
||||
return postSubscribeYTjs(authorId);
|
||||
}
|
||||
|
||||
@@ -327,6 +351,12 @@ export async function postSubscribe(
|
||||
|
||||
export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestInit = {}) {
|
||||
if (isYTBackend()) {
|
||||
// deleteUnsubscribeYTjs still should run
|
||||
// as cleans feeds of that channel.
|
||||
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
|
||||
deleteUnsubscribeBackend(authorId);
|
||||
}
|
||||
|
||||
return deleteUnsubscribeYTjs(authorId);
|
||||
}
|
||||
|
||||
@@ -401,7 +431,7 @@ export async function getPlaylist(
|
||||
|
||||
let resp;
|
||||
|
||||
if (get(authStore)) {
|
||||
if (get(invidiousAuthStore)) {
|
||||
resp = await fetch(buildPath(`auth/playlists/${playlistId}?page=${page}`), {
|
||||
...buildAuthHeaders(),
|
||||
...fetchOptions
|
||||
@@ -518,7 +548,7 @@ export async function getThumbnail(
|
||||
}
|
||||
|
||||
function buildApiExtendedAuthHeaders(): Record<string, Record<string, string>> {
|
||||
const authToken = get(authStore)?.token ?? '';
|
||||
const authToken = get(invidiousAuthStore)?.token ?? '';
|
||||
return { headers: { Authorization: `Bearer ${authToken.replace('SID=', '')}` } };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { CommentsOptions, SearchOptions } from './model';
|
||||
import { engineFallbacksStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { isUnrestrictedPlatform } from '$lib/misc';
|
||||
|
||||
export function searchSetDefaults(options: SearchOptions) {
|
||||
if (typeof options.sort_by === 'undefined') {
|
||||
@@ -34,5 +34,5 @@ export type EngineFallback =
|
||||
| 'Playlist';
|
||||
|
||||
export function useEngineFallback(fallback: EngineFallback): boolean {
|
||||
return get(engineFallbacksStore).includes(fallback) && Capacitor.isNativePlatform();
|
||||
return get(engineFallbacksStore).includes(fallback) && isUnrestrictedPlatform();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { localDb } from '$lib/dexie';
|
||||
import { clearCaches } from '$lib/misc';
|
||||
import { localDb, type ChannelSubscriptions } from '$lib/dexie';
|
||||
import { cleanNumber } from '$lib/numbers';
|
||||
import { relativeTimestamp } from '$lib/time';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -8,8 +7,10 @@ import { getChannelYTjs } from './channel';
|
||||
import {
|
||||
engineCooldownYTStore,
|
||||
engineCullYTStore,
|
||||
engineMaxConcurrentChannelsStore
|
||||
engineMaxConcurrentChannelsStore,
|
||||
rawMasterKeyStore
|
||||
} from '$lib/store';
|
||||
import { getSubscriptionsBackend, updateRSSLastUpdated } from '../backend';
|
||||
|
||||
export async function getSubscriptionsYTjs(): Promise<Subscription[]> {
|
||||
const subscriptions: Subscription[] = [];
|
||||
@@ -54,7 +55,6 @@ export async function postSubscribeYTjs(
|
||||
export async function deleteUnsubscribeYTjs(authorId: string) {
|
||||
await localDb.channelSubscriptions.where('channelId').equals(authorId).delete();
|
||||
await localDb.subscriptionFeed.where('authorId').equals(authorId).delete();
|
||||
clearCaches();
|
||||
}
|
||||
|
||||
export async function parseChannelRSS(channelId: string): Promise<void> {
|
||||
@@ -132,14 +132,28 @@ export async function parseChannelRSS(channelId: string): Promise<void> {
|
||||
// Continue regardless of error
|
||||
}
|
||||
|
||||
await localDb.channelSubscriptions.where('channelId').equals(channelId).modify({
|
||||
lastRSSFetch: new Date()
|
||||
});
|
||||
if (!get(rawMasterKeyStore)) {
|
||||
localDb.channelSubscriptions.where('channelId').equals(channelId).modify({
|
||||
lastRSSFetch: new Date()
|
||||
});
|
||||
} else {
|
||||
updateRSSLastUpdated(authorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearFeedYTjs() {
|
||||
await localDb.subscriptionFeed.clear();
|
||||
}
|
||||
|
||||
export async function getFeedYTjs(maxResults: number, page: number): Promise<Feed> {
|
||||
const channelSubscriptions = await localDb.channelSubscriptions.toArray();
|
||||
let channelSubscriptions: ChannelSubscriptions[];
|
||||
|
||||
if (!get(rawMasterKeyStore)) {
|
||||
channelSubscriptions = await localDb.channelSubscriptions.toArray();
|
||||
} else {
|
||||
channelSubscriptions = await getSubscriptionsBackend();
|
||||
}
|
||||
|
||||
const toUpdatePromises: Promise<void>[] = [];
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ import { convertToSeconds } from '$lib/time';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Types } from 'youtubei.js';
|
||||
import { Utils, YT, YTNodes, Platform } from 'youtubei.js';
|
||||
import { Utils, YT, YTNodes, Platform, type IGetChallengeResponse } from 'youtubei.js';
|
||||
import { getInnertube } from '.';
|
||||
import { isUnrestrictedPlatform } from '$lib/misc';
|
||||
import { webPoTokenMinter } from '$lib/web/youtube/minter';
|
||||
|
||||
Platform.shim.eval = async (
|
||||
data: Types.BuildScriptResult,
|
||||
@@ -36,7 +38,7 @@ Platform.shim.eval = async (
|
||||
};
|
||||
|
||||
export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
if (!isUnrestrictedPlatform()) {
|
||||
throw new Error('Platform not supported');
|
||||
}
|
||||
|
||||
@@ -44,10 +46,23 @@ export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
|
||||
|
||||
const requestKey = 'O43z0dpjhgX20SCx4KAo';
|
||||
|
||||
const platformMinter =
|
||||
Capacitor.getPlatform() === 'android'
|
||||
? androidPoTokenMinter
|
||||
: window.electronAPI.generatePoToken;
|
||||
let platformMinter: (
|
||||
requestKey: string,
|
||||
visitorData: string,
|
||||
challenge: IGetChallengeResponse
|
||||
) => Promise<string>;
|
||||
|
||||
switch (Capacitor.getPlatform()) {
|
||||
case 'electron':
|
||||
platformMinter = window.electronAPI.generatePoToken;
|
||||
break;
|
||||
case 'android':
|
||||
platformMinter = androidPoTokenMinter;
|
||||
break;
|
||||
default:
|
||||
platformMinter = webPoTokenMinter;
|
||||
break;
|
||||
}
|
||||
|
||||
const clientPlaybackNonce = Utils.generateRandomString(16);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { Image } from '$lib/api/model';
|
||||
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
|
||||
import { isYTBackend, truncate } from '$lib/misc';
|
||||
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
|
||||
import { invidiousAuthStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { localDb } from '$lib/dexie';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -71,7 +71,7 @@
|
||||
</nav>
|
||||
</a>
|
||||
{#if !hideSubscribe}
|
||||
{#if $authStore || isYTBackend()}
|
||||
{#if $invidiousAuthStore || isYTBackend()}
|
||||
<nav class="group split">
|
||||
<button
|
||||
onclick={toggleSubscribed}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Thumbnail from '$lib/components/Thumbnail.svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { removePlaylistVideo } from '../api';
|
||||
import { authStore, feedLastItemId, isAndroidTvStore } from '../store';
|
||||
import { invidiousAuthStore, feedLastItemId, isAndroidTvStore } from '../store';
|
||||
import ContentColumn from './ContentColumn.svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import Mousetrap from 'mousetrap';
|
||||
@@ -260,7 +260,7 @@
|
||||
{#key item.videoId}
|
||||
<Thumbnail video={item} {playlistId} />
|
||||
{/key}
|
||||
{#if $authStore && decodeURIComponent($authStore.username) === playlistAuthor && 'indexId' in item}
|
||||
{#if $invidiousAuthStore && decodeURIComponent($invidiousAuthStore.username) === playlistAuthor && 'indexId' in item}
|
||||
<div class="right-align" style="margin: 1em .5em;">
|
||||
<button
|
||||
onclick={async () => removePlaylistItem(item.indexId)}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
|
||||
import type { VideoPlay } from '../api/model';
|
||||
import {
|
||||
authStore,
|
||||
instanceStore,
|
||||
invidiousAuthStore,
|
||||
invidiousInstanceStore,
|
||||
isAndroidTvStore,
|
||||
playerAlwaysLoopStore,
|
||||
playerAndroidLockOrientation,
|
||||
@@ -45,6 +45,7 @@
|
||||
} from '../store';
|
||||
import { setStatusBarColor } from '../theme';
|
||||
import { getVideoYTjs } from '$lib/api/youtubejs/video';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {
|
||||
goToNextVideo,
|
||||
goToPreviousVideo,
|
||||
@@ -58,7 +59,7 @@
|
||||
import { Network, type ConnectionStatus } from '@capacitor/network';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { addToast } from './Toast.svelte';
|
||||
import { isMobile, isYTBackend, truncate } from '$lib/misc';
|
||||
import { isMobile, isUnrestrictedPlatform, isYTBackend, truncate } from '$lib/misc';
|
||||
import {
|
||||
generateThumbnailWebVTT,
|
||||
drawTimelineThumbnail,
|
||||
@@ -515,8 +516,11 @@
|
||||
// Due to CORs issues with redirects, hosted instances of Materialious
|
||||
// dirctly provide the companion instance
|
||||
// while clients can just use the reirect provided by Invidious' API
|
||||
if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
|
||||
dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`;
|
||||
if (
|
||||
import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE ||
|
||||
env.PUBLIC_DEFAULT_COMPANION_INSTANCE
|
||||
) {
|
||||
dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE || env.PUBLIC_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`;
|
||||
} else {
|
||||
if (!data.video.dashUrl) {
|
||||
error(500, 'No dash manifest found');
|
||||
@@ -524,7 +528,7 @@
|
||||
dashUrl = data.video.dashUrl;
|
||||
}
|
||||
|
||||
if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || $playerProxyVideosStore)) {
|
||||
if (!data.video.fallbackPatch && (!isUnrestrictedPlatform() || $playerProxyVideosStore)) {
|
||||
dashUrl += '?local=true';
|
||||
}
|
||||
|
||||
@@ -547,7 +551,9 @@
|
||||
playerMaxKnownTime
|
||||
);
|
||||
} else if (!data.video.fallbackPatch) {
|
||||
const thumbnailVTTResp = await fetch(`${$instanceStore}${selectedStoryboard.url}`);
|
||||
const thumbnailVTTResp = await fetch(
|
||||
`${$invidiousInstanceStore}${selectedStoryboard.url}`
|
||||
);
|
||||
if (thumbnailVTTResp.ok) thumbnailVTT = await thumbnailVTTResp.text();
|
||||
}
|
||||
|
||||
@@ -576,12 +582,16 @@
|
||||
if (data.video.captions) {
|
||||
for (const caption of data.video.captions) {
|
||||
let captionUrl: string;
|
||||
if (!import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
|
||||
if (
|
||||
!import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE &&
|
||||
!env.PUBLIC_DEFAULT_COMPANION_INSTANCE &&
|
||||
$invidiousInstanceStore
|
||||
) {
|
||||
captionUrl = caption.url.startsWith('http')
|
||||
? caption.url
|
||||
: `${new URL(get(instanceStore)).origin}${caption.url}`;
|
||||
: `${new URL($invidiousInstanceStore).origin}${caption.url}`;
|
||||
} else {
|
||||
captionUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}${caption.url}`;
|
||||
captionUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE || env.PUBLIC_DEFAULT_COMPANION_INSTANCE}${caption.url}`;
|
||||
}
|
||||
|
||||
await player.addTextTrackAsync(
|
||||
@@ -1035,7 +1045,7 @@
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
!Capacitor.isNativePlatform() ||
|
||||
!isUnrestrictedPlatform() ||
|
||||
data.video.fallbackPatch === 'youtubejs' ||
|
||||
(error as shaka.extern.Error).code !== 1001
|
||||
)
|
||||
@@ -1079,7 +1089,7 @@
|
||||
// Continue regardless of error
|
||||
}
|
||||
|
||||
if ($synciousStore && $synciousInstanceStore && $authStore) {
|
||||
if ($synciousStore && $synciousInstanceStore && $invidiousAuthStore) {
|
||||
try {
|
||||
toSetTime = (await getVideoProgress(data.video.videoId))[0].time;
|
||||
} catch {
|
||||
@@ -1093,7 +1103,7 @@
|
||||
function savePlayerPos() {
|
||||
if (data.video.liveNow) return;
|
||||
|
||||
const synciousEnabled = $synciousStore && $synciousInstanceStore && $authStore;
|
||||
const synciousEnabled = $synciousStore && $synciousInstanceStore && $invidiousAuthStore;
|
||||
|
||||
if ($playerSavePlaybackPositionStore && playerElement) {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
type Answer = {
|
||||
text: string;
|
||||
action: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
answers,
|
||||
question,
|
||||
info = undefined
|
||||
}: {
|
||||
answers: Answer[];
|
||||
question: string;
|
||||
info?: string;
|
||||
} = $props();
|
||||
|
||||
const coloumSize = answers.length > 2 ? '4' : '6';
|
||||
|
||||
let selectedAnswer = $state('');
|
||||
function selectAnswer(answer: Answer) {
|
||||
selectedAnswer = answer.text;
|
||||
answer.action();
|
||||
}
|
||||
</script>
|
||||
|
||||
<h3>{question}</h3>
|
||||
<div class="grid">
|
||||
{#each answers as answer (answer)}
|
||||
<div class={`s12 m${coloumSize} l${coloumSize}`}>
|
||||
<article
|
||||
style="cursor: pointer;"
|
||||
role="presentation"
|
||||
tabindex="-1"
|
||||
onclick={() => selectAnswer(answer)}
|
||||
class:primary={selectedAnswer === answer.text}
|
||||
class:surface-container-highest={selectedAnswer !== answer.text}
|
||||
>
|
||||
<h6>{answer.text}</h6>
|
||||
</article>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if info}
|
||||
<p><i>info</i> {info}</p>
|
||||
{/if}
|
||||
@@ -39,6 +39,7 @@
|
||||
}
|
||||
|
||||
function onSubmit(event: Event | undefined = undefined) {
|
||||
clearTimeout(debounceTimer);
|
||||
event?.preventDefault();
|
||||
|
||||
goToSearch(search);
|
||||
@@ -51,6 +52,7 @@
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
const direction = event.key === 'ArrowUp' ? -1 : 1;
|
||||
const container = document.querySelector('.suggestions-container');
|
||||
if (container) {
|
||||
@@ -73,12 +75,18 @@
|
||||
}
|
||||
}
|
||||
} else if (event.key === 'Enter' && selectedSuggestionIndex !== -1) {
|
||||
event.preventDefault();
|
||||
|
||||
search = suggestionsForSearch[selectedSuggestionIndex];
|
||||
onSubmit();
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
|
||||
resetSearch();
|
||||
event.preventDefault();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
|
||||
resetSearch();
|
||||
}
|
||||
}
|
||||
@@ -139,6 +147,7 @@
|
||||
bind:value={search}
|
||||
onkeydown={handleKeyDown}
|
||||
onkeyup={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
} else {
|
||||
@@ -156,6 +165,7 @@
|
||||
search = suggestion;
|
||||
onSubmit();
|
||||
}}
|
||||
type="reset"
|
||||
class="transparent suggestion"
|
||||
class:selected={index === selectedSuggestionIndex}
|
||||
>
|
||||
@@ -171,6 +181,7 @@
|
||||
onclick={() => {
|
||||
search = history;
|
||||
}}
|
||||
type="reset"
|
||||
class="transparent suggestion"
|
||||
>
|
||||
<div>{history}</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { instanceStore } from '$lib/store';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { invidiousInstanceStore } from '$lib/store';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
|
||||
import { shareURL } from '$lib/misc';
|
||||
import { isUnrestrictedPlatform, shareURL } from '$lib/misc';
|
||||
import { addToast } from './Toast.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -45,8 +44,8 @@
|
||||
class="row"
|
||||
role="presentation"
|
||||
onclick={async () => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
shareVideo(`${get(instanceStore)}/watch/${video.videoId}`);
|
||||
if (isUnrestrictedPlatform()) {
|
||||
shareVideo(`${get(invidiousInstanceStore)}/watch/${video.videoId}`);
|
||||
} else {
|
||||
shareVideo(
|
||||
`${location.origin}${resolve('/watch/[videoId]', { videoId: video.videoId })}`,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { createVideoUrl, insecureRequestImageHandler, isYTBackend } from '../misc';
|
||||
import type { PlayerEvents } from '../player';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
deArrowEnabledStore,
|
||||
interfaceLowBandwidthMode,
|
||||
isAndroidTvStore,
|
||||
@@ -108,7 +108,7 @@
|
||||
thumbnail = img;
|
||||
};
|
||||
|
||||
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
|
||||
if (get(synciousStore) && get(synciousInstanceStore) && get(invidiousAuthStore)) {
|
||||
try {
|
||||
progress = (await queueGetWatchProgress(video.videoId))?.time?.toString() ?? undefined;
|
||||
} catch {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import { type VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VideoPlay } from '../api/model';
|
||||
import { decodeHtmlCharCodes } from '../misc';
|
||||
import { instanceStore } from '../store';
|
||||
import { invidiousInstanceStore } from '../store';
|
||||
|
||||
interface Props {
|
||||
video: VideoPlay;
|
||||
@@ -46,11 +45,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let urlConstructed = '';
|
||||
|
||||
if (video.fallbackPatch === 'youtubejs') {
|
||||
urlConstructed = url;
|
||||
} else if ($invidiousInstanceStore) {
|
||||
urlConstructed = new URL($invidiousInstanceStore).origin + url;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
transcript = null;
|
||||
const resp = await fetch(
|
||||
`${!video.fallbackPatch ? new URL(get(instanceStore)).origin : ''}${url}`
|
||||
);
|
||||
const resp = await fetch(urlConstructed);
|
||||
transcript = await parseText(await resp.text(), { strict: false });
|
||||
transcriptCues = transcript.cues;
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
)}
|
||||
>
|
||||
<nav>
|
||||
<div class="field prefix label border max">
|
||||
<div class="field prefix label surface-container-highest max">
|
||||
<i>link</i>
|
||||
<input tabindex="0" bind:value={synciousInstance} name="syncious-instance" type="text" />
|
||||
<label tabindex="-1" for="syncious-instance">{$_('layout.instanceUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<button class="square">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
|
||||
<form onsubmit={preventDefault(() => deArrowInstanceStore.set(ensureNoTrailingSlash(deArrowUrl)))}>
|
||||
<nav>
|
||||
<div class="field prefix label border max">
|
||||
<div class="field prefix label surface-container-highest max">
|
||||
<i>link</i>
|
||||
<input bind:value={deArrowUrl} name="dearrow-instance" type="text" />
|
||||
<label for="dearrow-instance">{$_('layout.instanceUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<button class="square">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -30,12 +30,12 @@
|
||||
|
||||
<form onsubmit={preventDefault(() => deArrowThumbnailInstanceStore.set(deArrowThumbnailUrl))}>
|
||||
<nav>
|
||||
<div class="field prefix label border max">
|
||||
<div class="field prefix label surface-container-highest max">
|
||||
<i>link</i>
|
||||
<input bind:value={deArrowThumbnailUrl} name="dearrow-thumbnail-instance" type="text" />
|
||||
<label for="dearrow-thumbnail-instance">{$_('layout.deArrow.thumbnailInstanceUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<button class="square">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { _ } from '$lib/i18n';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
engineCooldownYTStore,
|
||||
engineCullYTStore,
|
||||
engineFallbacksStore,
|
||||
engineMaxConcurrentChannelsStore,
|
||||
instanceStore
|
||||
invidiousInstanceStore
|
||||
} from '$lib/store';
|
||||
import { useEngineFallback, type EngineFallback } from '$lib/api/misc';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
{#if isYTBackend()}
|
||||
<h6>Feed</h6>
|
||||
<div class="field label prefix border">
|
||||
<div class="field label prefix surface-container-highest">
|
||||
<i>view_stream</i>
|
||||
<input
|
||||
oninput={(event: Event) => {
|
||||
@@ -109,7 +109,7 @@
|
||||
/>
|
||||
<label for="cull">{$_('layout.backendEngine.cull')}</label>
|
||||
</div>
|
||||
<div class="field label prefix border">
|
||||
<div class="field label prefix surface-container-highest">
|
||||
<i>schedule</i>
|
||||
<input
|
||||
oninput={(event: Event) => {
|
||||
@@ -121,7 +121,7 @@
|
||||
/>
|
||||
<label for="cooldown">{$_('layout.backendEngine.cooldown')}</label>
|
||||
</div>
|
||||
<div class="field label prefix border">
|
||||
<div class="field label prefix surface-container-highest">
|
||||
<i>pending</i>
|
||||
<input
|
||||
oninput={(event: Event) => {
|
||||
@@ -134,7 +134,7 @@
|
||||
<label for="concurrent">{$_('layout.backendEngine.concurrent')}</label>
|
||||
</div>
|
||||
|
||||
{#if $authStore && $instanceStore}
|
||||
{#if $invidiousAuthStore && $invidiousInstanceStore}
|
||||
<h6>{$_('layout.backendEngine.importExport')}</h6>
|
||||
<div class="space"></div>
|
||||
|
||||
|
||||
@@ -11,14 +11,20 @@
|
||||
import type { RgbaColor, HsvaColor, Colord } from 'colord';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { ensureNoTrailingSlash, isMobile, clearCaches } from '../../misc';
|
||||
import {
|
||||
isMobile,
|
||||
clearCaches,
|
||||
isUnrestrictedPlatform,
|
||||
setInvidiousInstance,
|
||||
goToInvidiousLogin
|
||||
} from '../../misc';
|
||||
import { getPages, type Pages } from '../../navPages';
|
||||
import ColorPicker from 'svelte-awesome-color-picker';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
backendInUseStore,
|
||||
darkModeStore,
|
||||
instanceStore,
|
||||
invidiousInstanceStore,
|
||||
interfaceAllowInsecureRequests,
|
||||
interfaceAmoledTheme,
|
||||
interfaceAndroidUseNativeShare,
|
||||
@@ -37,8 +43,9 @@
|
||||
} from '../../store';
|
||||
import { addToast } from '../Toast.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
|
||||
let invidiousInstance = $state(get(instanceStore));
|
||||
let invidiousInstance = $state(get(invidiousInstanceStore));
|
||||
let region = $state(get(interfaceRegionStore));
|
||||
let forceCase = $state(get(interfaceForceCase));
|
||||
let defaultPage = $state(get(interfaceDefaultPage));
|
||||
@@ -84,37 +91,8 @@
|
||||
|
||||
async function setInstance(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
invalidInstance = false;
|
||||
|
||||
const instance = ensureNoTrailingSlash(invidiousInstance);
|
||||
|
||||
try {
|
||||
new URL(instance);
|
||||
} catch {
|
||||
invalidInstance = true;
|
||||
}
|
||||
|
||||
if (invalidInstance) return;
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`${instance}/api/v1/channels/UCH-_hzb2ILSCo9ftVSnrCIQ`);
|
||||
} catch {
|
||||
invalidInstance = true;
|
||||
}
|
||||
|
||||
if (invalidInstance) return;
|
||||
|
||||
if (resp && !resp.ok) {
|
||||
invalidInstance = true;
|
||||
return;
|
||||
}
|
||||
|
||||
instanceStore.set(instance);
|
||||
|
||||
invalidInstance = !(await setInvidiousInstance(invidiousInstance));
|
||||
reloadState();
|
||||
authStore.set(null);
|
||||
}
|
||||
|
||||
async function setBackend(event: Event) {
|
||||
@@ -148,13 +126,13 @@
|
||||
}
|
||||
|
||||
let pages: Pages = $state([]);
|
||||
authStore.subscribe(() => {
|
||||
invidiousAuthStore.subscribe(() => {
|
||||
pages = getPages();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if Capacitor.isNativePlatform()}
|
||||
<div class="field label suffix border">
|
||||
{#if isUnrestrictedPlatform()}
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select name="backend-in-use" onchange={setBackend}>
|
||||
<option selected={$backendInUseStore === 'ivg'} value="ivg">Invidious</option>
|
||||
<option selected={$backendInUseStore === 'yt'} value="yt">YouTube (Experimental)</option>
|
||||
@@ -166,7 +144,10 @@
|
||||
{#if $backendInUseStore === 'ivg'}
|
||||
<form onsubmit={setInstance}>
|
||||
<nav>
|
||||
<div class="field prefix label border max" class:invalid={invalidInstance}>
|
||||
<div
|
||||
class="field prefix label surface-container-highest max"
|
||||
class:invalid={invalidInstance}
|
||||
>
|
||||
<i>link</i>
|
||||
<input
|
||||
tabindex="0"
|
||||
@@ -179,19 +160,36 @@
|
||||
<span class="error">{$_('invalidInstance')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="square round">
|
||||
<button class="square">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
{#if isOwnBackend()?.internalAuth}
|
||||
{#if !$invidiousAuthStore}
|
||||
<button onclick={goToInvidiousLogin}>
|
||||
<i>link</i>
|
||||
<span>{$_('linkInvidious')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => {
|
||||
invidiousAuthStore.set(null);
|
||||
goto(resolve('/', {}));
|
||||
}}
|
||||
>
|
||||
<i>link_off</i>
|
||||
<span>{$_('unlinkInvidious')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<div class="space"></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="space"></div>
|
||||
{/if}
|
||||
|
||||
{#if isMobile()}
|
||||
{#if invalidInstance}
|
||||
<div style="margin-bottom: 6em;"></div>
|
||||
{/if}
|
||||
{#if isMobile() && invalidInstance}
|
||||
<div style="margin-bottom: 6em;"></div>
|
||||
{/if}
|
||||
|
||||
{#if Capacitor.isNativePlatform() && (invalidInstance || $interfaceAllowInsecureRequests)}
|
||||
@@ -408,7 +406,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select
|
||||
tabindex="0"
|
||||
name="region"
|
||||
@@ -425,7 +423,7 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select
|
||||
tabindex="0"
|
||||
name="case"
|
||||
@@ -443,7 +441,7 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select
|
||||
name="defaultPage"
|
||||
tabindex="0"
|
||||
@@ -451,7 +449,7 @@
|
||||
onchange={() => interfaceDefaultPage.set(defaultPage)}
|
||||
>
|
||||
{#each pages as page (page)}
|
||||
{#if !page.requiresAuth || get(authStore)}
|
||||
{#if !page.requiresAuth || get(invidiousAuthStore)}
|
||||
<option selected={$interfaceDefaultPage === page.href} value={page.href}>{page.name}</option
|
||||
>
|
||||
{/if}
|
||||
@@ -465,6 +463,7 @@
|
||||
<div class="space"></div>
|
||||
<div class="settings">
|
||||
<h6>{$_('layout.bookmarklet')}</h6>
|
||||
<div class="space"></div>
|
||||
<button
|
||||
class="no-margin"
|
||||
onclick={async () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { _ } from '$lib/i18n';
|
||||
import { logout } from '$lib/misc';
|
||||
|
||||
let clickCount = $state(0);
|
||||
const clicksToDelte = 3;
|
||||
let deleteDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||
async function deleteAccount() {
|
||||
clickCount++;
|
||||
|
||||
if (deleteDebounce) clearTimeout(deleteDebounce);
|
||||
|
||||
deleteDebounce = setTimeout(() => {
|
||||
clickCount = 0;
|
||||
}, 10000);
|
||||
|
||||
if (clicksToDelte - clickCount === 0) {
|
||||
await fetch('/api/user/delete', { method: 'DELETE' });
|
||||
logout();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h6>Delete account</h6>
|
||||
<div class="space"></div>
|
||||
<button class="tertiary" onclick={deleteAccount}>
|
||||
<i>warning</i>
|
||||
<span>{$_('layout.deleteAccount')}</span>
|
||||
{#if clicksToDelte - clickCount > 0}
|
||||
<div class="tooltip">
|
||||
{$_('layout.clickXmoreTimesToDelete', {
|
||||
clicksTillDelete: clicksToDelte - clickCount
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -22,7 +22,7 @@
|
||||
playerYouTubeJsFallback
|
||||
} from '../../store';
|
||||
import { playbackRates } from '$lib/player';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { isUnrestrictedPlatform, isYTBackend } from '$lib/misc';
|
||||
|
||||
let defaultLanguage = $state(get(playerDefaultLanguage));
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
</script>
|
||||
|
||||
<div class="margin"></div>
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select
|
||||
tabindex="0"
|
||||
name="case"
|
||||
@@ -84,7 +84,7 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select
|
||||
tabindex="0"
|
||||
name="quality"
|
||||
@@ -106,7 +106,7 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
<div class="field label suffix border">
|
||||
<div class="field label suffix surface-container-highest">
|
||||
<select
|
||||
tabindex="0"
|
||||
name="quality"
|
||||
@@ -122,8 +122,8 @@
|
||||
<i>arrow_drop_down</i>
|
||||
</div>
|
||||
|
||||
{#if Capacitor.isNativePlatform() && !isYTBackend()}
|
||||
<div class="field suffix border label">
|
||||
{#if isUnrestrictedPlatform() && !isYTBackend()}
|
||||
<div class="field suffix surface-container-highest label">
|
||||
<select
|
||||
tabindex="0"
|
||||
name="ytfallback"
|
||||
@@ -243,7 +243,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if Capacitor.isNativePlatform()}
|
||||
{#if isUnrestrictedPlatform()}
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
)}
|
||||
>
|
||||
<nav>
|
||||
<div class="field prefix label border max">
|
||||
<div class="field prefix label surface-container-highest max">
|
||||
<i>link</i>
|
||||
<input tabindex="0" bind:value={returnYTInstance} name="returnyt-instance" type="text" />
|
||||
<label tabindex="-1" for="returnyt-instance">{$_('layout.instanceUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<button class="square">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
import Player from './Player.svelte';
|
||||
import Ryd from './RYD.svelte';
|
||||
import SponsorBlock from './SponsorBlock.svelte';
|
||||
import { isAndroidTvStore } from '$lib/store';
|
||||
import { isAndroidTvStore, rawMasterKeyStore } from '$lib/store';
|
||||
import About from './About.svelte';
|
||||
import Engine from './Engine.svelte';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { isUnrestrictedPlatform } from '$lib/misc';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import InternalAccount from './InternalAccount.svelte';
|
||||
|
||||
let activeTab = $state('interface');
|
||||
const isActive = (id: string) => activeTab === id;
|
||||
|
||||
const tabs: { id: string; label: string; icon: string; component: Component }[] = [
|
||||
let tabs: { id: string; label: string; icon: string; component: Component }[] = $state([
|
||||
{ id: 'interface', label: $_('layout.interface'), icon: 'grid_view', component: Interface },
|
||||
{ id: 'player', label: $_('layout.player.title'), icon: 'smart_display', component: Player },
|
||||
{ id: 'ryd', label: 'Return YT Dislike', icon: 'thumb_down', component: Ryd },
|
||||
@@ -33,9 +35,13 @@
|
||||
icon: 'info',
|
||||
component: About
|
||||
}
|
||||
];
|
||||
]);
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
let tabIds: string[] = $state([]);
|
||||
|
||||
setTabIds();
|
||||
|
||||
if (isUnrestrictedPlatform()) {
|
||||
tabs.splice(1, 0, {
|
||||
id: 'engine',
|
||||
label: $_('layout.engine'),
|
||||
@@ -44,7 +50,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
const tabIds = tabs.map((tab) => tab.id);
|
||||
function setTabIds() {
|
||||
tabs.forEach((tab) => {
|
||||
tabIds.push(tab.id);
|
||||
});
|
||||
}
|
||||
|
||||
rawMasterKeyStore.subscribe((value) => {
|
||||
if (isOwnBackend()?.internalAuth && value) {
|
||||
tabs.splice(1, 0, {
|
||||
id: 'account',
|
||||
label: $_('layout.materialiousAccount'),
|
||||
icon: 'person',
|
||||
component: InternalAccount
|
||||
});
|
||||
} else {
|
||||
tabs = tabs.filter((tab) => {
|
||||
return tab.id !== 'account';
|
||||
});
|
||||
}
|
||||
|
||||
setTabIds();
|
||||
});
|
||||
|
||||
let dialogType = $state('');
|
||||
|
||||
@@ -102,24 +129,26 @@
|
||||
|
||||
<div style="height: 100%;">
|
||||
<nav class="wrap s">
|
||||
<button class="large small-round surface-container-highest max" data-ui="#tab-menu">
|
||||
<i>{tabs[tabIds.indexOf(activeTab)].icon}</i>
|
||||
<span>{tabs[tabIds.indexOf(activeTab)].label}</span>
|
||||
<menu style="width: 100%;" data-ui="#tab-menu" id="tab-menu">
|
||||
{#each tabs as tab (tab)}
|
||||
<li
|
||||
onclick={() => {
|
||||
activeTab = tab.id;
|
||||
}}
|
||||
role="presentation"
|
||||
data-ui="#tab-menu"
|
||||
>
|
||||
<i>{tab.icon}</i>
|
||||
<span>{tab.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</menu>
|
||||
</button>
|
||||
{#if tabIds}
|
||||
<button class="large small-round surface-container-highest max" data-ui="#tab-menu">
|
||||
<i>{tabs[tabIds.indexOf(activeTab)]?.icon}</i>
|
||||
<span>{tabs[tabIds.indexOf(activeTab)]?.label}</span>
|
||||
<menu style="width: 100%;" data-ui="#tab-menu" id="tab-menu">
|
||||
{#each tabs as tab (tab)}
|
||||
<li
|
||||
onclick={() => {
|
||||
activeTab = tab.id;
|
||||
}}
|
||||
role="presentation"
|
||||
data-ui="#tab-menu"
|
||||
>
|
||||
<i>{tab.icon}</i>
|
||||
<span>{tab.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</menu>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<div class="s">
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
)}
|
||||
>
|
||||
<nav>
|
||||
<div class="field prefix label border max">
|
||||
<div class="field prefix label surface-container-highest max">
|
||||
<i>link</i>
|
||||
<input
|
||||
tabindex="0"
|
||||
@@ -58,7 +58,7 @@
|
||||
/>
|
||||
<label tabindex="-1" for="sponsorblock-instance">{$_('layout.instanceUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<button class="square">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
@@ -119,7 +119,7 @@
|
||||
<div class="max">
|
||||
<p>{sponsor.name}</p>
|
||||
</div>
|
||||
<div class="field suffix border">
|
||||
<div class="field suffix surface-container-highest">
|
||||
<select onchange={(event) => onSponsorSet(sponsor.category, event)}>
|
||||
<option selected={currentCatergoryTrigger === undefined} value="disabled"
|
||||
>{$_('disabled')}</option
|
||||
|
||||
@@ -129,6 +129,10 @@ menu {
|
||||
color: var(--on-surface) !important;
|
||||
}
|
||||
|
||||
.altcha {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1000px) {
|
||||
menu.mobile {
|
||||
position: fixed !important;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { page } from '$app/state';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
import {
|
||||
darkModeStore,
|
||||
deArrowEnabledStore,
|
||||
@@ -269,9 +271,13 @@ function setStores(toSet: Record<string, unknown>, overwriteExisting = false) {
|
||||
}
|
||||
|
||||
export function loadSettingsFromEnv() {
|
||||
if (typeof import.meta.env.VITE_DEFAULT_SETTINGS !== 'string') return;
|
||||
if (
|
||||
typeof import.meta.env.VITE_DEFAULT_SETTINGS !== 'string' &&
|
||||
typeof env.PUBLIC_DEFAULT_SETTINGS !== 'string'
|
||||
)
|
||||
return;
|
||||
|
||||
let raw = import.meta.env.VITE_DEFAULT_SETTINGS;
|
||||
let raw = import.meta.env.VITE_DEFAULT_SETTINGS || env.PUBLIC_DEFAULT_SETTINGS;
|
||||
|
||||
// Docker wraps env vars in quotes
|
||||
if (raw.startsWith('"')) raw = raw.slice(1);
|
||||
|
||||
+23
-12
@@ -1,8 +1,12 @@
|
||||
import { timeout } from '$lib/misc';
|
||||
import { isUnrestrictedPlatform, timeout } from '$lib/misc';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
const corsProxyUrl: string = 'http://localhost:3000/';
|
||||
export const originalFetch = window.fetch;
|
||||
const corsProxyUrl =
|
||||
Capacitor.getPlatform() === 'android'
|
||||
? 'http://localhost:3000/'
|
||||
: `${location.origin}/api/proxy/`;
|
||||
|
||||
let mobileNodeLoaded = false;
|
||||
|
||||
function needsProxying(target: string): boolean {
|
||||
@@ -10,11 +14,15 @@ function needsProxying(target: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const androidFetch = async (
|
||||
function encodeUrl(url: string): string {
|
||||
return Capacitor.getPlatform() === 'android' ? url : encodeURIComponent(url);
|
||||
}
|
||||
|
||||
export const fetchProxied = async (
|
||||
requestInput: string | URL | Request,
|
||||
requestOptions?: RequestInit
|
||||
): Promise<Response> => {
|
||||
if (!mobileNodeLoaded) {
|
||||
if (!mobileNodeLoaded && Capacitor.getPlatform() === 'android') {
|
||||
let mobileNodeResp: Response | undefined;
|
||||
|
||||
while (!mobileNodeResp || !mobileNodeResp.ok) {
|
||||
@@ -29,11 +37,14 @@ export const androidFetch = async (
|
||||
mobileNodeLoaded = true;
|
||||
}
|
||||
|
||||
const uri = requestInput instanceof Request ? requestInput.url : requestInput.toString();
|
||||
const nonProxiedUrl =
|
||||
requestInput instanceof Request ? requestInput.url : requestInput.toString();
|
||||
|
||||
if (needsProxying(nonProxiedUrl)) {
|
||||
const proxiedUrl = corsProxyUrl + encodeUrl(nonProxiedUrl);
|
||||
|
||||
if (needsProxying(uri)) {
|
||||
if (requestInput instanceof Request) {
|
||||
requestInput = new Request(corsProxyUrl + uri, {
|
||||
requestInput = new Request(proxiedUrl, {
|
||||
method: requestInput.method,
|
||||
headers: requestInput.headers,
|
||||
body: requestInput.body,
|
||||
@@ -47,7 +58,7 @@ export const androidFetch = async (
|
||||
...(requestInput.body ? { duplex: 'half' } : {})
|
||||
});
|
||||
} else {
|
||||
requestInput = corsProxyUrl + uri;
|
||||
requestInput = proxiedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +66,14 @@ export const androidFetch = async (
|
||||
return originalFetch(requestInput, requestOptions);
|
||||
};
|
||||
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
window.fetch = androidFetch;
|
||||
if (isUnrestrictedPlatform() && Capacitor.getPlatform() !== 'electron') {
|
||||
window.fetch = fetchProxied;
|
||||
|
||||
const originalXhrOpen = XMLHttpRequest.prototype.open;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (...args: any[]): void {
|
||||
if (needsProxying(args[1])) {
|
||||
args[1] = corsProxyUrl + args[1];
|
||||
args[1] = corsProxyUrl + encodeUrl(args[1]);
|
||||
}
|
||||
// @ts-expect-error args have any type
|
||||
return originalXhrOpen.apply(this, args);
|
||||
@@ -7,9 +7,21 @@
|
||||
"loadMore": "Load more",
|
||||
"views": "views",
|
||||
"login": "Login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"invalidPassword": "Invalid password",
|
||||
"usernameTaken": "Username taken",
|
||||
"linkInvidious": "Link Invidious account",
|
||||
"unlinkInvidious": "Unlink Invidious account",
|
||||
"createAccount": "Create account",
|
||||
"needRegister": "I need to register",
|
||||
"registrationDisabled": "Registration currently disabled, contact instance owner.",
|
||||
"needLogin": "I need to login",
|
||||
"recommendedVideos": "Recommended Videos",
|
||||
"playlistVideos": "Playlist Videos",
|
||||
"invidiousLogin": "Please log in with your Invidious account",
|
||||
"invidiousLogin": "Please log in with your Invidious account.",
|
||||
"materialiousLogin": "Please login with your Materialious account.",
|
||||
"materialiousCreate": "Please create your Materialious account.",
|
||||
"invalidInstance": "Please verify the URL. If it's correct, the instance may be down or may not support third-party clients.",
|
||||
"invidiousBlockWarning": "Invidious is currently being blocked by Google. If videos aren't loading for this instance, please use this instance on Materialious on {android} or {desktop} to get around this with local video fallback.",
|
||||
"videos": "videos",
|
||||
@@ -24,6 +36,18 @@
|
||||
"sortBy": "Sort By",
|
||||
"features": "Features"
|
||||
},
|
||||
"initalSetup": {
|
||||
"useInvidious": "I want to use Invidious",
|
||||
"useLocalFallback": "I want to use local video fallback",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unsure": "Unsure",
|
||||
"required": "Setup Required",
|
||||
"invidiousInfo": "Invidious is an Self-hostable YouTube interface.",
|
||||
"localFallbackInfo": "When a video fails loading from Invidious, Materialious loads it locally.",
|
||||
"configureInstance": "Configure instance",
|
||||
"done": "Done"
|
||||
},
|
||||
"title": "Title",
|
||||
"searchPlaceholder": "Search",
|
||||
"delete": "Delete",
|
||||
@@ -145,6 +169,7 @@
|
||||
"engine": "Backend engine",
|
||||
"syncParty": "Sync party",
|
||||
"about": "About",
|
||||
"materialiousAccount": "Account",
|
||||
"syncPartyWarning": "Please note your IP will be visible to users you invite.",
|
||||
"startSyncParty": "Start sync party",
|
||||
"endSyncParty": "End sync party",
|
||||
@@ -160,6 +185,8 @@
|
||||
"displayThumbnailAvatars": "Thumbnail avatars (Slow)",
|
||||
"searchHistory": "Search history",
|
||||
"companionUrl": "Companion URL",
|
||||
"deleteAccount": "Delete account",
|
||||
"clickXmoreTimesToDelete": "Click {{clicksTillDelete}} more time(s) to delete",
|
||||
"theme": {
|
||||
"theme": "Theme",
|
||||
"darkMode": "Dark mode",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get } from 'svelte/store';
|
||||
import type { Image } from './api/model';
|
||||
import { instanceStore } from './store';
|
||||
import { invidiousInstanceStore } from './store';
|
||||
import { isYTBackend } from './misc';
|
||||
|
||||
export class ImageCache {
|
||||
@@ -66,5 +66,5 @@ export function proxyGoogleImage(source: string): string {
|
||||
|
||||
if (typeof path === 'undefined') return '';
|
||||
|
||||
return `${get(instanceStore)}/ggpht${path}`;
|
||||
return `${get(invidiousInstanceStore)}/ggpht${path}`;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@ import { resolve } from '$app/paths';
|
||||
import he from 'he';
|
||||
import type Peer from 'peerjs';
|
||||
import { get } from 'svelte/store';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
backendInUseStore,
|
||||
channelCacheStore,
|
||||
feedCacheStore,
|
||||
invidiousInstanceStore,
|
||||
interfaceAllowInsecureRequests,
|
||||
interfaceAndroidUseNativeShare,
|
||||
isAndroidTvStore,
|
||||
playlistCacheStore,
|
||||
rawMasterKeyStore,
|
||||
searchCacheStore
|
||||
} from './store';
|
||||
import type {
|
||||
@@ -27,6 +30,9 @@ import { page } from '$app/state';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Share } from '@capacitor/share';
|
||||
import { Clipboard } from '@capacitor/clipboard';
|
||||
import { isOwnBackend } from './shared';
|
||||
import { Browser } from '@capacitor/browser';
|
||||
import { clearFeedYTjs } from './api/youtubejs/subscriptions';
|
||||
|
||||
export function isMobile(): boolean {
|
||||
const userAgent = navigator.userAgent;
|
||||
@@ -80,9 +86,10 @@ export interface PeerInstance {
|
||||
|
||||
export function determinePeerJsInstance(): PeerInstance {
|
||||
return {
|
||||
host: import.meta.env.VITE_DEFAULT_PEERJS_HOST || '0.peerjs.com',
|
||||
path: import.meta.env.VITE_DEFAULT_PEERJS_PATH || '/',
|
||||
port: import.meta.env.VITE_DEFAULT_PEERJS_PORT || 443
|
||||
host:
|
||||
import.meta.env.VITE_DEFAULT_PEERJS_HOST || env.PUBLIC_DEFAULT_PEERJS_HOST || '0.peerjs.com',
|
||||
path: import.meta.env.VITE_DEFAULT_PEERJS_PATH || env.PUBLIC_DEFAULT_PEERJS_PATH || '/',
|
||||
port: import.meta.env.VITE_DEFAULT_PEERJS_PORT || env.PUBLIC_DEFAULT_PEERJS_PORT || 443
|
||||
};
|
||||
}
|
||||
|
||||
@@ -229,8 +236,12 @@ export function findElementForTime<T>(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isUnrestrictedPlatform(): boolean {
|
||||
return isOwnBackend() !== null || Capacitor.isNativePlatform();
|
||||
}
|
||||
|
||||
export function isYTBackend(): boolean {
|
||||
return get(backendInUseStore) === 'yt' && Capacitor.isNativePlatform();
|
||||
return get(backendInUseStore) === 'yt' && isUnrestrictedPlatform();
|
||||
}
|
||||
|
||||
export function clearCaches() {
|
||||
@@ -241,7 +252,77 @@ export function clearCaches() {
|
||||
}
|
||||
|
||||
export function authProtected() {
|
||||
if (!get(authStore) && !isYTBackend()) {
|
||||
if (!get(invidiousAuthStore) && !isYTBackend()) {
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function setInvidiousInstance(
|
||||
instanceUrl: string | undefined | null
|
||||
): Promise<boolean> {
|
||||
if (typeof instanceUrl !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let invalidInstance = false;
|
||||
|
||||
const instance = ensureNoTrailingSlash(instanceUrl);
|
||||
|
||||
try {
|
||||
new URL(instance);
|
||||
} catch {
|
||||
invalidInstance = true;
|
||||
}
|
||||
|
||||
if (invalidInstance) return false;
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`${instance}/api/v1/channels/UCH-_hzb2ILSCo9ftVSnrCIQ`);
|
||||
} catch {
|
||||
invalidInstance = true;
|
||||
}
|
||||
|
||||
if (invalidInstance) return false;
|
||||
|
||||
if (resp && !resp.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
invidiousInstanceStore.set(instance);
|
||||
invidiousAuthStore.set(null);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function goToInvidiousLogin() {
|
||||
if (!get(invidiousInstanceStore)) return;
|
||||
const path = new URL(`${get(invidiousInstanceStore)}/authorize_token`);
|
||||
const searchParams = new URLSearchParams({
|
||||
scopes: ':feed,:subscriptions*,:playlists*,:history*,:notifications*'
|
||||
});
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
searchParams.set('callback_url', 'materialious-auth://');
|
||||
path.search = searchParams.toString();
|
||||
await Browser.open({ url: path.toString() });
|
||||
} else {
|
||||
searchParams.set('callback_url', `${location.origin}${resolve('/auth', {})}`);
|
||||
path.search = searchParams.toString();
|
||||
document.location.href = path.toString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
if (isYTBackend()) {
|
||||
await clearFeedYTjs();
|
||||
}
|
||||
|
||||
if (isOwnBackend()?.internalAuth) {
|
||||
fetch('/api/user/logout', { method: 'DELETE' });
|
||||
rawMasterKeyStore.set(undefined);
|
||||
} else {
|
||||
invidiousAuthStore.set(null);
|
||||
}
|
||||
|
||||
goto(resolve('/', {}));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { isYTBackend } from './misc';
|
||||
import { authStore } from './store';
|
||||
import { invidiousAuthStore } from './store';
|
||||
|
||||
export type Pages = { icon: string; href: string; name: string; requiresAuth: boolean }[];
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getPages(): Pages {
|
||||
];
|
||||
|
||||
pages = pages.filter((page) => {
|
||||
return !page.requiresAuth || (get(authStore) && !isYTBackend());
|
||||
return !page.requiresAuth || (get(invidiousAuthStore) && !isYTBackend());
|
||||
});
|
||||
|
||||
return pages;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { VideoPlay } from '$lib/api/model';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
|
||||
import type shaka from 'shaka-player/dist/shaka-player.ui';
|
||||
import { ShakaPlayerAdapter } from './ShakaPlayerAdapter';
|
||||
@@ -7,12 +6,13 @@ import { Constants, YT } from 'youtubei.js';
|
||||
import { get } from 'svelte/store';
|
||||
import { poTokenCacheStore } from '$lib/store';
|
||||
import { buildSabrFormat } from 'googlevideo/utils';
|
||||
import { isUnrestrictedPlatform } from '$lib/misc';
|
||||
|
||||
export async function injectSabr(
|
||||
video: VideoPlay,
|
||||
player: shaka.Player
|
||||
): Promise<SabrStreamingAdapter | null> {
|
||||
if (!video.ytjs || !Capacitor.isNativePlatform()) return null;
|
||||
if (!video.ytjs || !isUnrestrictedPlatform()) return null;
|
||||
|
||||
const sabrAdapter = new SabrStreamingAdapter({
|
||||
playerAdapter: new ShakaPlayerAdapter(),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { verifySolution } from 'altcha-lib';
|
||||
import { getSequelize } from './database';
|
||||
|
||||
export async function verifyCaptcha(payload: string, key: string, maxUses: number = -1) {
|
||||
const passedCaptcha = await verifySolution(payload, key, true);
|
||||
if (!passedCaptcha) {
|
||||
throw error(400, 'Unsupported payload');
|
||||
}
|
||||
|
||||
let captchaSignature: string | undefined;
|
||||
try {
|
||||
const decodedCaptcha = JSON.parse(Buffer.from(payload, 'base64').toString());
|
||||
|
||||
captchaSignature = decodedCaptcha.signature;
|
||||
} catch {
|
||||
// Handle error outside of catch
|
||||
}
|
||||
|
||||
if (typeof captchaSignature === 'undefined') {
|
||||
throw error(400, 'Unsupported payload');
|
||||
}
|
||||
|
||||
if (maxUses !== -1) {
|
||||
if (
|
||||
(await getSequelize().CaptchaTable.count({ where: { signature: captchaSignature } })) >=
|
||||
maxUses
|
||||
) {
|
||||
throw error(400, 'Unsupported payload');
|
||||
}
|
||||
|
||||
await getSequelize().CaptchaTable.create({
|
||||
signature: captchaSignature,
|
||||
created: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Sequelize, DataTypes, Model, type ModelCtor } from 'sequelize';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
let UserTable: ModelCtor<Model<any, any>>;
|
||||
let ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
|
||||
let CaptchaTable: ModelCtor<Model<any, any>>;
|
||||
|
||||
let sequelizeInstance: Sequelize | null = null;
|
||||
|
||||
// Don't want to make our Sequelize instance always
|
||||
export function getSequelize(): {
|
||||
sequelize: Sequelize;
|
||||
UserTable: ModelCtor<Model<any, any>>;
|
||||
ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
|
||||
CaptchaTable: ModelCtor<Model<any, any>>;
|
||||
} {
|
||||
if (sequelizeInstance) {
|
||||
return {
|
||||
sequelize: sequelizeInstance,
|
||||
UserTable,
|
||||
ChannelSubscriptionTable,
|
||||
CaptchaTable
|
||||
};
|
||||
}
|
||||
|
||||
if (!env.DATABASE_CONNECTION_URI) {
|
||||
throw new Error('DATABASE_CONNECTION_URI must be set');
|
||||
}
|
||||
|
||||
sequelizeInstance = new Sequelize(env.DATABASE_CONNECTION_URI);
|
||||
|
||||
UserTable = sequelizeInstance.define(
|
||||
'User',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUIDV4,
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
unique: true
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
passwordHash: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
passwordSalt: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
created: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
decryptionKeySalt: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
masterKeyCipher: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
masterKeyNonce: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['id', 'username']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
ChannelSubscriptionTable = sequelizeInstance.define('Subscriptions', {
|
||||
id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
primaryKey: true
|
||||
},
|
||||
channelIdCipher: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
channelIdNonce: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
channelNameCipher: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
channelNameNonce: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
lastRSSFetch: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
CaptchaTable = sequelizeInstance.define('Captchas', {
|
||||
signature: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
primaryKey: true
|
||||
},
|
||||
created: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
});
|
||||
|
||||
UserTable.hasMany(ChannelSubscriptionTable);
|
||||
|
||||
return {
|
||||
sequelize: sequelizeInstance,
|
||||
UserTable,
|
||||
ChannelSubscriptionTable,
|
||||
CaptchaTable
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChannelSubscriptionModel {
|
||||
id: string; // Hashed authId with subscription key on client.
|
||||
channelIdCipher: string;
|
||||
channelIdNonce: string;
|
||||
channelNameCipher: string;
|
||||
channelNameNonce: string;
|
||||
lastRSSFetch: Date;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface UserTableModel extends Model {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
created: Date;
|
||||
decryptionKeySalt: string;
|
||||
masterKeyCipher: string;
|
||||
masterKeyNonce: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { sign } from 'cookie-signature';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export function setAuthCookie(id: string, cookies: Cookies) {
|
||||
cookies.set('userid', sign(id, env.COOKIE_SECRET as string), {
|
||||
httpOnly: true,
|
||||
path: '/api',
|
||||
maxAge: 60 * 60 * 24 * 60 // 60 days
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { getSequelize, type ChannelSubscriptionModel, type UserTableModel } from './database';
|
||||
import { Op } from 'sequelize';
|
||||
import crypto from 'crypto';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export class User {
|
||||
data: UserTableModel;
|
||||
|
||||
constructor(data: UserTableModel) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public get id() {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public get publicPasswordSalts() {
|
||||
return {
|
||||
decryptionKeySalt: this.data.decryptionKeySalt,
|
||||
passwordSalt: this.data.passwordSalt
|
||||
};
|
||||
}
|
||||
|
||||
private get userWhere() {
|
||||
return {
|
||||
where: {
|
||||
[Op.or]: [{ id: this.data.id }, { username: this.data.username }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await getSequelize().UserTable.destroy(this.userWhere);
|
||||
}
|
||||
|
||||
async subscriptionRssUpdated(id: string) {
|
||||
await getSequelize().ChannelSubscriptionTable.update(
|
||||
{ lastRSSFetch: new Date() },
|
||||
{
|
||||
where: {
|
||||
id: id,
|
||||
UserId: this.id
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async addSubscription(subscription: Omit<ChannelSubscriptionModel, 'userId'>) {
|
||||
await getSequelize().ChannelSubscriptionTable.create({
|
||||
...subscription,
|
||||
UserId: this.id
|
||||
});
|
||||
}
|
||||
|
||||
async removeSubscription(id: string) {
|
||||
await getSequelize().ChannelSubscriptionTable.destroy({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async amSubscribed(id: string): Promise<boolean> {
|
||||
return (
|
||||
(await getSequelize().ChannelSubscriptionTable.count({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
})) > 0
|
||||
);
|
||||
}
|
||||
|
||||
async subscriptions(): Promise<ChannelSubscriptionModel[]> {
|
||||
const subscriptions = await getSequelize().ChannelSubscriptionTable.findAll({
|
||||
where: {
|
||||
userId: this.data.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!subscriptions) return [];
|
||||
|
||||
return subscriptions as unknown as ChannelSubscriptionModel[];
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateUser = {
|
||||
username: string;
|
||||
password: {
|
||||
hash: string;
|
||||
salt: string;
|
||||
};
|
||||
decryptionKeySalt: string;
|
||||
masterKey: {
|
||||
cipher: string;
|
||||
nonce: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function createUser(user: CreateUser): Promise<User> {
|
||||
const id = crypto.randomUUID().toString();
|
||||
|
||||
const createdUser = {
|
||||
id,
|
||||
username: user.username,
|
||||
passwordHash: user.password.hash,
|
||||
passwordSalt: user.password.salt,
|
||||
created: new Date(),
|
||||
decryptionKeySalt: user.decryptionKeySalt,
|
||||
masterKeyCipher: user.masterKey.cipher,
|
||||
masterKeyNonce: user.masterKey.nonce
|
||||
};
|
||||
|
||||
let userCreated = false;
|
||||
try {
|
||||
await getSequelize().UserTable.create(createdUser);
|
||||
userCreated = true;
|
||||
} catch {
|
||||
userCreated = false;
|
||||
}
|
||||
|
||||
if (!userCreated) {
|
||||
throw error(400);
|
||||
}
|
||||
|
||||
return new User(createdUser as UserTableModel);
|
||||
}
|
||||
|
||||
export async function getUser(identifier: string): Promise<User> {
|
||||
const user = await getSequelize().UserTable.findOne({
|
||||
where: {
|
||||
[Op.or]: [{ id: identifier }, { username: identifier }]
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw error(404);
|
||||
}
|
||||
|
||||
return new User(user as UserTableModel);
|
||||
}
|
||||
|
||||
export async function authenticateUser(username: string, passwordHash: string): Promise<User> {
|
||||
const user = await getSequelize().UserTable.findOne({
|
||||
where: {
|
||||
username
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw error(404);
|
||||
}
|
||||
|
||||
const userModel = user as UserTableModel;
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
// Password is hashed in the browser, so if db is leaked it doesn't matter.
|
||||
// Timing safe equal used to stop timing attacks when comparing strings.
|
||||
if (
|
||||
crypto.timingSafeEqual(
|
||||
textEncoder.encode(passwordHash),
|
||||
textEncoder.encode(userModel.passwordHash)
|
||||
)
|
||||
) {
|
||||
return new User(userModel);
|
||||
}
|
||||
|
||||
throw error(404);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export type IsOwnBackend = {
|
||||
builtWithBackend: boolean;
|
||||
internalAuth: boolean;
|
||||
requireAuth: boolean;
|
||||
registrationAllowed: boolean;
|
||||
allowAnyProxy: boolean;
|
||||
};
|
||||
|
||||
export function isOwnBackend(): IsOwnBackend | null {
|
||||
if (env.PUBLIC_BUILD_WITH_BACKEND !== 'true') return null;
|
||||
|
||||
return {
|
||||
builtWithBackend: true,
|
||||
internalAuth: env.PUBLIC_INTERNAL_AUTH !== 'false',
|
||||
requireAuth: env.PUBLIC_REQUIRE_AUTH !== 'false',
|
||||
registrationAllowed: env.PUBLIC_REGISTRATION_ALLOWED === 'true',
|
||||
allowAnyProxy: env.PUBLIC_DANGEROUS_ALLOW_ANY_PROXY === 'true'
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type Peer from 'peerjs';
|
||||
import type { DataConnection } from 'peerjs';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import {
|
||||
persist,
|
||||
createLocalStorage,
|
||||
@@ -86,14 +87,13 @@ function ifNotWebDefault(givenValue: any, defaultValue: any): any {
|
||||
}
|
||||
}
|
||||
|
||||
export const instanceStore: Writable<string> = persist(
|
||||
export const invidiousInstanceStore: Writable<string | undefined> = persist(
|
||||
writable(
|
||||
ifNotWebDefault(
|
||||
!import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE
|
||||
? undefined
|
||||
: ensureNoTrailingSlash(import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE),
|
||||
'https://invidious.materialio.us'
|
||||
)
|
||||
!import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE || !env.PUBLIC_DEFAULT_INVIDIOUS_INSTANCE
|
||||
? undefined
|
||||
: ensureNoTrailingSlash(
|
||||
import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE || env.PUBLIC_DEFAULT_INVIDIOUS_INSTANCE
|
||||
)
|
||||
),
|
||||
createStorage(),
|
||||
'invidiousInstance'
|
||||
@@ -105,7 +105,7 @@ export const backendInUseStore: Writable<'ivg' | 'yt'> = persist(
|
||||
'backendInUse'
|
||||
);
|
||||
|
||||
export const authStore: Writable<null | { username: string; token: string }> = persist(
|
||||
export const invidiousAuthStore: Writable<null | { username: string; token: string }> = persist(
|
||||
writable(null),
|
||||
createStorage(),
|
||||
'authToken'
|
||||
@@ -193,7 +193,8 @@ export const returnYtDislikesStore = persist(writable(false), createStorage(), '
|
||||
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persist(
|
||||
writable(
|
||||
ifNotWebDefault(
|
||||
import.meta.env.VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE,
|
||||
import.meta.env.VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE ||
|
||||
env.PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE,
|
||||
'https://ryd-proxy.materialio.us'
|
||||
)
|
||||
),
|
||||
@@ -206,7 +207,8 @@ export const synciousInstanceStore: Writable<string | null | undefined> = persis
|
||||
writable(
|
||||
ifNotWebDefault(
|
||||
import.meta.env.VITE_DEFAULT_SYNCIOUS_INSTANCE ||
|
||||
import.meta.env.VITE_DEFAULT_API_EXTENDED_INSTANCE,
|
||||
import.meta.env.VITE_DEFAULT_API_EXTENDED_INSTANCE ||
|
||||
env.PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE,
|
||||
'https://extended-api.materialio.us'
|
||||
)
|
||||
),
|
||||
@@ -279,7 +281,11 @@ export const interfaceAndroidUseNativeShare = persist(
|
||||
|
||||
export const sponsorBlockStore = persist(writable(true), createStorage(), 'sponsorBlock');
|
||||
export const sponsorBlockUrlStore: Writable<string | null | undefined> = persist(
|
||||
writable(import.meta.env.VITE_DEFAULT_SPONSERBLOCK_INSTANCE || 'https://sponsor.ajay.app'),
|
||||
writable(
|
||||
import.meta.env.VITE_DEFAULT_SPONSERBLOCK_INSTANCE ||
|
||||
env.PUBLIC_DEFAULT_SPONSERBLOCK_INSTANCE ||
|
||||
'https://sponsor.ajay.app'
|
||||
),
|
||||
createStorage(),
|
||||
'sponsorBlockUrl'
|
||||
);
|
||||
@@ -300,7 +306,11 @@ export const sponsorBlockTimelineStore: Writable<boolean> = persist(
|
||||
);
|
||||
|
||||
export const deArrowInstanceStore = persist(
|
||||
writable(import.meta.env.VITE_DEFAULT_DEARROW_INSTANCE || 'https://sponsor.ajay.app'),
|
||||
writable(
|
||||
import.meta.env.VITE_DEFAULT_DEARROW_INSTANCE ||
|
||||
env.PUBLIC_DEFAULT_DEARROW_INSTANCE ||
|
||||
'https://sponsor.ajay.app'
|
||||
),
|
||||
createStorage(),
|
||||
'deArrowInstance'
|
||||
);
|
||||
@@ -308,7 +318,9 @@ export const deArrowEnabledStore = persist(writable(false), createStorage(), 'de
|
||||
export const deArrowTitlesOnly = persist(writable(true), createStorage(), 'deArrowTitlesOnly');
|
||||
export const deArrowThumbnailInstanceStore = persist(
|
||||
writable(
|
||||
import.meta.env.VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE || 'https://dearrow-thumb.ajay.app'
|
||||
import.meta.env.VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE ||
|
||||
env.PUBLIC_DEFAULT_DEARROW_THUMBNAIL_INSTANCE ||
|
||||
'https://dearrow-thumb.ajay.app'
|
||||
),
|
||||
createStorage(),
|
||||
'deArrowThumbnailInstance'
|
||||
@@ -336,6 +348,12 @@ export const engineFallbacksStore: Writable<EngineFallback[]> = persist(
|
||||
'engineFallbacks'
|
||||
);
|
||||
|
||||
export const rawMasterKeyStore: Writable<string | undefined> = persist(
|
||||
writable(),
|
||||
createStorage(),
|
||||
'rawMasterKey'
|
||||
);
|
||||
|
||||
export const syncPartyPeerStore: Writable<Peer | null> = writable(null);
|
||||
export const syncPartyConnectionsStore: Writable<DataConnection[] | null> = writable();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '$lib/api/index';
|
||||
import { loadEntirePlaylist } from '$lib/playlist';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
playerProxyVideosStore,
|
||||
playerState,
|
||||
returnYTDislikesInstanceStore,
|
||||
@@ -38,7 +38,7 @@ export async function getWatchDetails(videoId: string, url: URL) {
|
||||
}
|
||||
|
||||
let personalPlaylists;
|
||||
if (get(authStore)) {
|
||||
if (get(invidiousAuthStore)) {
|
||||
postHistory(video.videoId);
|
||||
personalPlaylists = getPersonalPlaylists({ priority: 'low' });
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IGetChallengeResponse } from 'youtubei.js';
|
||||
|
||||
export async function webPoTokenMinter(
|
||||
requestKey: string,
|
||||
visitorData: string,
|
||||
challenge: IGetChallengeResponse
|
||||
): Promise<string> {
|
||||
const resp = await fetch('/api/poToken/', {
|
||||
body: JSON.stringify({
|
||||
requestKey,
|
||||
visitorData,
|
||||
challenge
|
||||
}),
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let errorMsg = 'An error occurred';
|
||||
try {
|
||||
errorMsg = (await resp.json()).message;
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return await resp.text();
|
||||
}
|
||||
@@ -17,14 +17,15 @@
|
||||
import { bookmarkletLoadFromUrl, loadSettingsFromEnv } from '$lib/externalSettings';
|
||||
import { getPages } from '$lib/navPages';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
darkModeStore,
|
||||
instanceStore,
|
||||
invidiousInstanceStore,
|
||||
interfaceAmoledTheme,
|
||||
interfaceDefaultPage,
|
||||
isAndroidTvStore,
|
||||
playerState,
|
||||
playertheatreModeIsActive,
|
||||
rawMasterKeyStore,
|
||||
syncPartyPeerStore,
|
||||
themeColorStore
|
||||
} from '$lib/store';
|
||||
@@ -39,23 +40,31 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { isYTBackend, clearCaches, truncate } from '$lib/misc';
|
||||
import { goToInvidiousLogin, isYTBackend, logout, truncate } from '$lib/misc';
|
||||
import Author from '$lib/components/Author.svelte';
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let mobileSearchShow = $state(false);
|
||||
const showLogin = !isYTBackend() || isOwnBackend()?.internalAuth;
|
||||
|
||||
let isLoggedIn = $state(false);
|
||||
authStore.subscribe((value) => {
|
||||
isLoggedIn = value !== null;
|
||||
});
|
||||
let mobileSearchShow = $state(false);
|
||||
|
||||
let notifications: Notification[] = $state([]);
|
||||
|
||||
let playerIsPip: boolean = $state(false);
|
||||
|
||||
let pages = $state(getPages());
|
||||
|
||||
invidiousAuthStore.subscribe(() => {
|
||||
pages = getPages();
|
||||
});
|
||||
|
||||
rawMasterKeyStore.subscribe(() => {
|
||||
pages = getPages();
|
||||
});
|
||||
|
||||
page.subscribe((pageData) => {
|
||||
playerIsPip = !pageData.url.pathname.includes('/watch');
|
||||
requestAnimationFrame(() => resetScroll());
|
||||
@@ -103,7 +112,7 @@
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (username && token) {
|
||||
authStore.set({
|
||||
invidiousAuthStore.set({
|
||||
username: username,
|
||||
token: token
|
||||
});
|
||||
@@ -112,22 +121,13 @@
|
||||
});
|
||||
|
||||
async function login() {
|
||||
if (isOwnBackend()?.internalAuth) {
|
||||
goto(resolve('/internal/login', {}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$isAndroidTvStore) {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const path = new URL(`${get(instanceStore)}/authorize_token`);
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const searchParams = new URLSearchParams({
|
||||
scopes: ':feed,:subscriptions*,:playlists*,:history*,:notifications*'
|
||||
});
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
searchParams.set('callback_url', 'materialious-auth://');
|
||||
path.search = searchParams.toString();
|
||||
await Browser.open({ url: path.toString() });
|
||||
} else {
|
||||
searchParams.set('callback_url', `${location.origin}${resolve('/auth', {})}`);
|
||||
path.search = searchParams.toString();
|
||||
document.location.href = path.toString();
|
||||
}
|
||||
await goToInvidiousLogin();
|
||||
} else {
|
||||
await ui('#tv-login');
|
||||
document.getElementById('username')?.focus();
|
||||
@@ -149,7 +149,7 @@
|
||||
body.append('password', rawPassword);
|
||||
body.append('action', 'signin');
|
||||
|
||||
const response = await fetch(`${$instanceStore}/login?type=invidious`, {
|
||||
const response = await fetch(`${$invidiousInstanceStore}/login?type=invidious`, {
|
||||
method: 'POST',
|
||||
body: body,
|
||||
headers: {
|
||||
@@ -167,7 +167,7 @@
|
||||
|
||||
if (sid) {
|
||||
console.log(sid);
|
||||
authStore.set({ username: rawUsername, token: sid });
|
||||
invidiousAuthStore.set({ username: rawUsername, token: sid });
|
||||
await ui('#tv-login');
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
return;
|
||||
@@ -178,12 +178,6 @@
|
||||
loginError = true;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStore.set(null);
|
||||
clearCaches();
|
||||
goto(resolve('/', {}));
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
const feed = await getFeed(100, 1);
|
||||
notifications = feed.notifications;
|
||||
@@ -237,8 +231,16 @@
|
||||
setTheme();
|
||||
setAmoledTheme();
|
||||
|
||||
if (isLoggedIn && !isYTBackend()) {
|
||||
loadNotifications().catch(() => authStore.set(null));
|
||||
if ($invidiousAuthStore && !isYTBackend()) {
|
||||
loadNotifications().catch(() => logout());
|
||||
}
|
||||
|
||||
if ($rawMasterKeyStore) {
|
||||
fetch('/api/user/isLoggedIn', { method: 'GET', credentials: 'same-origin' })
|
||||
.then((resp) => {
|
||||
if (!resp.ok) logout();
|
||||
})
|
||||
.catch(logout);
|
||||
}
|
||||
|
||||
resetScroll();
|
||||
@@ -259,7 +261,7 @@
|
||||
class:hide={$playertheatreModeIsActive}
|
||||
>
|
||||
<header role="presentation" style="cursor: pointer;" tabindex="-1" class="small-padding">
|
||||
<a href={resolve($interfaceDefaultPage, {})}>
|
||||
<a href={resolve($interfaceDefaultPage, {})} data-sveltekit-preload-data="off">
|
||||
<Logo />
|
||||
</a>
|
||||
</header>
|
||||
@@ -269,7 +271,7 @@
|
||||
<div>{$_('searchPlaceholder')}</div>
|
||||
</a>
|
||||
{/if}
|
||||
{#each getPages() as navPage (navPage)}
|
||||
{#each pages as navPage (navPage)}
|
||||
<a href={resolve(navPage.href, {})} class:active={$page.url.href.endsWith(navPage.href)}
|
||||
><i>{navPage.icon}</i>
|
||||
<div>{navPage.name}</div>
|
||||
@@ -281,8 +283,8 @@
|
||||
<i>settings</i>
|
||||
<div>{$_('layout.settings')}</div>
|
||||
</a>
|
||||
{#if !isYTBackend()}
|
||||
{#if !isLoggedIn}
|
||||
{#if showLogin}
|
||||
{#if (!$invidiousAuthStore && !isOwnBackend()?.internalAuth) || !$rawMasterKeyStore}
|
||||
<a onclick={login} href="#login">
|
||||
<i>login</i>
|
||||
<div>{$_('layout.login')}</div>
|
||||
@@ -344,7 +346,7 @@
|
||||
<i class:primary-text={$syncPartyPeerStore}>group</i>
|
||||
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
|
||||
</button>
|
||||
{#if isLoggedIn && !isYTBackend()}
|
||||
{#if $invidiousAuthStore && !isYTBackend()}
|
||||
<button
|
||||
class="circle large transparent"
|
||||
onclick={() => {
|
||||
@@ -367,8 +369,8 @@
|
||||
<div class="tooltip bottom">{$_('layout.settings')}</div>
|
||||
</button>
|
||||
|
||||
{#if !isYTBackend()}
|
||||
{#if !isLoggedIn}
|
||||
{#if showLogin}
|
||||
{#if (!$invidiousAuthStore && !isOwnBackend()?.internalAuth) || (!$rawMasterKeyStore && isOwnBackend()?.internalAuth)}
|
||||
<button onclick={login} class="circle large transparent">
|
||||
<i>login</i>
|
||||
<div class="tooltip bottom">{$_('layout.login')}</div>
|
||||
@@ -385,7 +387,7 @@
|
||||
{/if}
|
||||
|
||||
<nav class="bottom s">
|
||||
{#each getPages() as navPage (navPage)}
|
||||
{#each pages as navPage (navPage)}
|
||||
<a
|
||||
class="round"
|
||||
href={resolve(navPage.href, {})}
|
||||
@@ -486,11 +488,11 @@
|
||||
<form onsubmit={usernamePasswordLogin}>
|
||||
<div class="field label border" class:invalid={loginError}>
|
||||
<input id="username" bind:value={rawUsername} name="username" type="text" />
|
||||
<label for="username">Username</label>
|
||||
<label for="username">{$_('username')}</label>
|
||||
</div>
|
||||
<div class="field label border" class:invalid={loginError}>
|
||||
<input bind:value={rawPassword} name="password" type="password" />
|
||||
<label for="password">Password</label>
|
||||
<label for="password">{$_('password')}</label>
|
||||
</div>
|
||||
|
||||
<nav class="right-align no-space">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import { authStore } from '$lib/store';
|
||||
import { invidiousAuthStore } from '$lib/store';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Auth response handling for Desktop
|
||||
@@ -12,7 +12,7 @@
|
||||
const token = $page.url.searchParams.get('token');
|
||||
|
||||
if (username && token) {
|
||||
authStore.set({
|
||||
invidiousAuthStore.set({
|
||||
username: username,
|
||||
token: token
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getChannel, getChannelContent } from '$lib/api/index';
|
||||
import type { ChannelContentVideos, Video } from '$lib/api/model';
|
||||
import { excludeDuplicateFeeds } from '$lib/misc.js';
|
||||
import { channelCacheStore } from '$lib/store.js';
|
||||
import { excludeDuplicateFeeds } from '$lib/misc';
|
||||
import { channelCacheStore } from '$lib/store';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { createUserBackend, loginUserBackend, type DerivePassword } from '$lib/api/backend';
|
||||
import PageLoading from '$lib/components/PageLoading.svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import 'altcha';
|
||||
import * as comlink from 'comlink';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let needToRegister = $state(false);
|
||||
|
||||
let username = $state('');
|
||||
let rawPassword = $state('');
|
||||
let captchaPayload = $state('');
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let derivePassword: DerivePassword;
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
worker = new Worker(new URL('./workers/derivePassword.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
const workerApi = comlink.wrap(worker);
|
||||
|
||||
derivePassword = (workerApi as any).derivePassword as DerivePassword;
|
||||
});
|
||||
|
||||
let failed = $state(false);
|
||||
async function onLogin(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
isLoading = true;
|
||||
|
||||
if (needToRegister) {
|
||||
failed = !(await createUserBackend(username, rawPassword, captchaPayload, derivePassword));
|
||||
} else {
|
||||
failed = !(await loginUserBackend(username, rawPassword, captchaPayload, derivePassword));
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
|
||||
if (!failed) {
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<PageLoading />
|
||||
{:else}
|
||||
<nav class="center-align">
|
||||
<article class="padding left-align">
|
||||
<h3>{$_(needToRegister ? 'createAccount' : 'login')}</h3>
|
||||
<p class="no-margin">{$_(needToRegister ? 'materialiousCreate' : 'materialiousLogin')}</p>
|
||||
|
||||
<form onsubmit={onLogin}>
|
||||
<div
|
||||
class="field label prefix surface-container-highest"
|
||||
class:invalid={failed && needToRegister}
|
||||
>
|
||||
<i>person</i>
|
||||
<input bind:value={username} name="username" type="text" />
|
||||
<label for="username">{$_('username')}</label>
|
||||
{#if failed && needToRegister}
|
||||
<output class="invalid">{$_('usernameTaken')}</output>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="field label prefix surface-container-highest"
|
||||
class:invalid={failed && !needToRegister}
|
||||
>
|
||||
<i>password</i>
|
||||
<input bind:value={rawPassword} name="password" type="password" />
|
||||
<label for="password">{$_('password')}</label>
|
||||
{#if failed && !needToRegister}
|
||||
<output class="invalid">{$_('invalidPassword')}</output>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article
|
||||
class="surface-container-highest no-padding"
|
||||
style="width: 100%;height: fit-content;"
|
||||
>
|
||||
<altcha-widget
|
||||
challengeurl="/api/captcha"
|
||||
hidelogo
|
||||
hidefooter
|
||||
onstatechange={(ev) => {
|
||||
const { payload, state } = ev.detail;
|
||||
if (state === 'verified' && payload) {
|
||||
captchaPayload = payload;
|
||||
}
|
||||
}}
|
||||
></altcha-widget>
|
||||
</article>
|
||||
|
||||
<nav class="right-align">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary"
|
||||
disabled={!isOwnBackend()?.registrationAllowed}
|
||||
onclick={() => {
|
||||
needToRegister = !needToRegister;
|
||||
failed = false;
|
||||
}}
|
||||
>
|
||||
{#if !isOwnBackend()?.registrationAllowed}
|
||||
<div class="tooltip bottom">{$_('registrationDisabled')}</div>
|
||||
{/if}
|
||||
<span>{$_(!needToRegister ? 'needRegister' : 'needLogin')}</span>
|
||||
</button>
|
||||
|
||||
<button type="submit">
|
||||
<i>done</i>
|
||||
<span>{$_(needToRegister ? 'createAccount' : 'login')}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
</article>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
article {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
article {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
|
||||
export async function load() {
|
||||
if (!isOwnBackend()?.internalAuth) {
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as comlink from 'comlink';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
|
||||
async function derivePassword(rawPassword: string, passwordSalt: Uint8Array): Promise<Uint8Array> {
|
||||
await sodium.ready;
|
||||
|
||||
return sodium.crypto_pwhash(
|
||||
32,
|
||||
rawPassword,
|
||||
passwordSalt,
|
||||
sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
|
||||
sodium.crypto_pwhash_MEMLIMIT_SENSITIVE,
|
||||
sodium.crypto_pwhash_ALG_DEFAULT
|
||||
);
|
||||
}
|
||||
|
||||
comlink.expose({ derivePassword });
|
||||
@@ -5,7 +5,7 @@
|
||||
import { _ } from '$lib/i18n';
|
||||
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
|
||||
import ItemsList from '$lib/components/ItemsList.svelte';
|
||||
import type { SearchOptions, SearchResults } from '$lib/api/model.js';
|
||||
import type { SearchOptions, SearchResults } from '$lib/api/model';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import Question from '$lib/components/Question.svelte';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { clearCaches, isUnrestrictedPlatform, setInvidiousInstance } from '$lib/misc';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import { backendInUseStore, invidiousInstanceStore, playerYouTubeJsFallback } from '$lib/store';
|
||||
|
||||
const defaultInstance = !isOwnBackend()
|
||||
? 'https://invidious.materialio.us'
|
||||
: $invidiousInstanceStore;
|
||||
|
||||
let usingInvidious: boolean = $state(false);
|
||||
let invidiousInstanceValid: boolean = $state(true);
|
||||
let invidiousInstance: string = $state(defaultInstance ?? '');
|
||||
|
||||
async function setupCompleted() {
|
||||
invidiousInstanceValid = await setInvidiousInstance(invidiousInstance);
|
||||
|
||||
if (!invidiousInstanceValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCaches();
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
}
|
||||
|
||||
function setYTBackend() {
|
||||
usingInvidious = false;
|
||||
backendInUseStore.set('yt');
|
||||
setupCompleted();
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="center-align">
|
||||
<div class="setup">
|
||||
{#if !isUnrestrictedPlatform()}
|
||||
<p>
|
||||
<code>VITE_DEFAULT_INVIDIOUS_INSTANCE</code> has not been provided.
|
||||
</p>
|
||||
<p>
|
||||
Please read our <a
|
||||
href="https://github.com/Materialious/Materialious/blob/main/docs/DOCKER.md"
|
||||
referrerpolicy="no-referrer"
|
||||
class="link"
|
||||
>
|
||||
Docker guide
|
||||
</a>
|
||||
for configuring Materialious as a Invidious frontend.
|
||||
</p>
|
||||
<p>
|
||||
You will be required to reverse proxy Invidious & configure CORS too for Materialious to
|
||||
work.
|
||||
</p>
|
||||
{:else}
|
||||
<h3 class="center-align">{$_('initalSetup.required')}</h3>
|
||||
<div class="space"></div>
|
||||
<div class="divider"></div>
|
||||
<div class="space"></div>
|
||||
|
||||
<Question
|
||||
question={$_('initalSetup.useInvidious')}
|
||||
answers={[
|
||||
{
|
||||
text: $_('initalSetup.yes'),
|
||||
action: () => {
|
||||
usingInvidious = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
text: $_('initalSetup.no'),
|
||||
action: setYTBackend
|
||||
},
|
||||
{
|
||||
text: $_('initalSetup.unsure'),
|
||||
action: setYTBackend
|
||||
}
|
||||
]}
|
||||
info={$_('initalSetup.invidiousInfo')}
|
||||
/>
|
||||
|
||||
{#if usingInvidious}
|
||||
<div class="space"></div>
|
||||
|
||||
<Question
|
||||
question={$_('initalSetup.useLocalFallback')}
|
||||
answers={[
|
||||
{
|
||||
text: $_('initalSetup.yes'),
|
||||
action: () => {
|
||||
playerYouTubeJsFallback.set(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: $_('initalSetup.no'),
|
||||
action: () => {
|
||||
playerYouTubeJsFallback.set(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: $_('initalSetup.unsure'),
|
||||
action: () => {
|
||||
playerYouTubeJsFallback.set(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
info={$_('initalSetup.localFallbackInfo')}
|
||||
/>
|
||||
|
||||
<div class="space"></div>
|
||||
<h3>{$_('initalSetup.configureInstance')}</h3>
|
||||
<div
|
||||
class="field label prefix surface-container-highest"
|
||||
class:invalid={!invidiousInstanceValid && invidiousInstance !== ''}
|
||||
>
|
||||
<i>link</i>
|
||||
<input bind:value={invidiousInstance} name="instanceUrl" type="text" tabindex="0" />
|
||||
<label for="instanceUrl" tabindex="-1">{$_('layout.instanceUrl')}</label>
|
||||
{#if !invidiousInstanceValid && invidiousInstance !== ''}
|
||||
<span class="error">{$_('invalidInstance')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space"></div>
|
||||
|
||||
<button onclick={setupCompleted}>
|
||||
<i>done_all</i>
|
||||
<span>{$_('initalSetup.done')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.setup {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.setup {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { invidiousInstanceStore } from '$lib/store';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export async function load() {
|
||||
if (isYTBackend() || get(invidiousInstanceStore)) {
|
||||
goto(resolve('/', {}), { replaceState: true });
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
import { cleanNumber, numberWithCommas } from '$lib/numbers';
|
||||
import { goToNextVideo, goToPreviousVideo, type PlayerEvents } from '$lib/player';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
interfaceAutoExpandChapters,
|
||||
interfaceAutoExpandComments,
|
||||
playerMiniplayerEnabled,
|
||||
@@ -39,8 +39,8 @@
|
||||
import LikesDislikes from '$lib/components/watch/LikesDislikes.svelte';
|
||||
import Comment from '$lib/components/watch/Comment.svelte';
|
||||
import { expandSummery, isYTBackend } from '$lib/misc';
|
||||
import { humanizeSeconds, relativeTimestamp } from '$lib/time.js';
|
||||
import { getWatchDetails } from '$lib/watch.js';
|
||||
import { humanizeSeconds, relativeTimestamp } from '$lib/time';
|
||||
import { getWatchDetails } from '$lib/watch';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
@@ -490,7 +490,7 @@
|
||||
<button disabled class="surface-container-highest">
|
||||
<i>add</i>
|
||||
<div class="tooltip">
|
||||
{#if $authStore || isYTBackend()}
|
||||
{#if $invidiousAuthStore || isYTBackend()}
|
||||
{$_('player.noPlaylists')}
|
||||
{:else}
|
||||
{$_('loginRequired')}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { isAndroidTvStore } from '$lib/store';
|
||||
import { App } from '@capacitor/app';
|
||||
import '$lib/fetchProxy';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
|
||||
@@ -7,19 +7,22 @@ import '$lib/i18n'; // Import to initialize. Important :)
|
||||
import { initI18n } from '$lib/i18n';
|
||||
import { getPages } from '$lib/navPages';
|
||||
import {
|
||||
authStore,
|
||||
invidiousAuthStore,
|
||||
backendInUseStore,
|
||||
instanceStore,
|
||||
invidiousInstanceStore,
|
||||
interfaceDefaultPage,
|
||||
isAndroidTvStore
|
||||
isAndroidTvStore,
|
||||
rawMasterKeyStore
|
||||
} from '$lib/store';
|
||||
import { get, type Writable } from 'svelte/store';
|
||||
import '$lib/android/http/androidRequests';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { deserialize } from '@macfja/serializer';
|
||||
import { isYTBackend } from '$lib/misc';
|
||||
import { isOwnBackend } from '$lib/shared/index';
|
||||
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
|
||||
export async function load({ url }) {
|
||||
if (browser) {
|
||||
@@ -28,13 +31,14 @@ export async function load({ url }) {
|
||||
|
||||
isAndroidTvStore.set((await androidTv.isAndroidTv()).value);
|
||||
|
||||
// Due to race condition with how we set & save persistent store values
|
||||
// we manually set stores like auth & instance before load.
|
||||
if (Capacitor.getPlatform() === 'android') {
|
||||
// Due to race condition with how we set & save persistent store values
|
||||
// we manually set stores like auth & instance before load.
|
||||
const preferenceKey: Record<string, Writable<any>> = {
|
||||
invidiousInstance: instanceStore,
|
||||
authToken: authStore,
|
||||
backendInUse: backendInUseStore
|
||||
invidiousInstance: invidiousInstanceStore,
|
||||
authToken: invidiousAuthStore,
|
||||
backendInUse: backendInUseStore,
|
||||
rawMasterKey: rawMasterKeyStore
|
||||
};
|
||||
|
||||
for (const [key, store] of Object.entries(preferenceKey)) {
|
||||
@@ -67,9 +71,20 @@ export async function load({ url }) {
|
||||
window.history.length < 3
|
||||
) {
|
||||
getPages().forEach((page) => {
|
||||
if (page.href === defaultPage && (!page.requiresAuth || get(authStore))) {
|
||||
if (page.href === defaultPage && (!page.requiresAuth || get(invidiousAuthStore))) {
|
||||
goto(resolve(defaultPage, {}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const isLoginPage = url.pathname.endsWith('/internal/login');
|
||||
const isSetupPage = url.pathname.endsWith('/setup');
|
||||
|
||||
if (!isLoginPage) {
|
||||
if (isOwnBackend()?.requireAuth && !get(rawMasterKeyStore)) {
|
||||
goto(resolve('/internal/login', {}), { replaceState: true });
|
||||
} else if (!get(invidiousInstanceStore) && !isYTBackend() && !isSetupPage) {
|
||||
goto(resolve('/setup', {}), { replaceState: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
declare module 'virtual:pwa-info';
|
||||
declare module 'psl';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createChallenge } from 'altcha-lib';
|
||||
|
||||
export async function GET({ locals }) {
|
||||
const currentDate = new Date();
|
||||
|
||||
return json(
|
||||
await createChallenge({
|
||||
hmacKey: locals.captchaKey,
|
||||
expires: new Date(currentDate.getTime() + 1 * 60 * 60 * 1000)
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { IGetChallengeResponse } from 'youtubei.js';
|
||||
import BG, { buildURL, GOOG_API_KEY, USER_AGENT, type WebPoSignalOutput } from 'bgutils-js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import z from 'zod';
|
||||
import { isOwnBackend } from '$lib/shared/index';
|
||||
|
||||
const zPoTokenGenSchema = z.object({
|
||||
requestKey: z.string(),
|
||||
visitorData: z.string(),
|
||||
challenge: z.record(z.any(), z.any())
|
||||
});
|
||||
|
||||
export async function POST({ request, locals }) {
|
||||
if (isOwnBackend()?.requireAuth && !locals.userId) {
|
||||
throw error(401);
|
||||
}
|
||||
|
||||
const data = zPoTokenGenSchema.safeParse(await request.json());
|
||||
|
||||
if (!data.success) {
|
||||
throw error(400, data.error.message);
|
||||
}
|
||||
const challenge = data.data.challenge as IGetChallengeResponse;
|
||||
const requestKey = data.data.requestKey;
|
||||
const visitorData = data.data.visitorData;
|
||||
|
||||
const youtubeUrl = 'https://www.youtube.com/';
|
||||
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html lang="en"><head><title>YouTube</title></head><body></body></html>',
|
||||
{
|
||||
url: youtubeUrl,
|
||||
referrer: youtubeUrl,
|
||||
userAgent: USER_AGENT
|
||||
}
|
||||
);
|
||||
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
location: dom.window.location,
|
||||
origin: dom.window.origin
|
||||
});
|
||||
|
||||
if (!Reflect.has(globalThis, 'navigator')) {
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator });
|
||||
}
|
||||
|
||||
if (!challenge.bg_challenge) {
|
||||
throw error(400, 'BotGuard challenge not provided');
|
||||
}
|
||||
|
||||
const interpreterUrl =
|
||||
challenge.bg_challenge.interpreter_url
|
||||
.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
|
||||
const interpreterHash = challenge.bg_challenge.interpreter_hash;
|
||||
|
||||
if (!interpreterUrl || !interpreterHash) {
|
||||
throw error(
|
||||
500,
|
||||
`Could not get integrity token. Interpreter Hash: ${challenge.bg_challenge?.interpreter_hash}`
|
||||
);
|
||||
}
|
||||
|
||||
const interpreterResponse = await fetch(`https:${interpreterUrl}`, {
|
||||
headers: {
|
||||
'user-agent': USER_AGENT
|
||||
}
|
||||
});
|
||||
|
||||
if (!interpreterResponse.ok) {
|
||||
throw new Error('Unable to fetch interpreter');
|
||||
}
|
||||
|
||||
const interpreterJavascript = await interpreterResponse.text();
|
||||
|
||||
if (interpreterJavascript) {
|
||||
new Function(interpreterJavascript)();
|
||||
} else throw error(500, 'Could not load VM');
|
||||
|
||||
const botguardClient = await BG.BotGuardClient.create({
|
||||
program: challenge.bg_challenge.program,
|
||||
globalName: challenge.bg_challenge.global_name,
|
||||
globalObj: globalThis
|
||||
});
|
||||
|
||||
const webPoSignalOutput: WebPoSignalOutput = [];
|
||||
const botguardResponse = await botguardClient.snapshot({ webPoSignalOutput });
|
||||
|
||||
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json+protobuf',
|
||||
'x-goog-api-key': GOOG_API_KEY,
|
||||
'x-user-agent': 'grpc-web-javacript/0.1'
|
||||
},
|
||||
body: JSON.stringify([requestKey, botguardResponse])
|
||||
});
|
||||
|
||||
const integrityTokenResponseData = await integrityTokenResponse.json();
|
||||
const integrityToken = integrityTokenResponseData[0] as string | undefined;
|
||||
|
||||
if (!integrityToken) {
|
||||
throw error(500, `Could not get integrity token. Interpreter Hash: ${interpreterHash}`);
|
||||
}
|
||||
|
||||
const integrityTokenBasedMinter = await BG.WebPoMinter.create(
|
||||
{ integrityToken },
|
||||
webPoSignalOutput
|
||||
);
|
||||
|
||||
return new Response(
|
||||
await integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(visitorData)),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import psl from 'psl';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
const allowedDomains: string[] = [
|
||||
'youtube.com',
|
||||
'ytimg.com',
|
||||
'googlevideo.com',
|
||||
'returnyoutubedislike.com',
|
||||
'sponsor.ajay.app',
|
||||
'dearrow-thumb.ajay.app',
|
||||
'materialio.us'
|
||||
];
|
||||
|
||||
const dynamicAllowDomains = [
|
||||
env.PUBLIC_DEFAULT_DEARROW_THUMBNAIL_INSTANCE,
|
||||
env.PUBLIC_DEFAULT_DEARROW_INSTANCE,
|
||||
env.PUBLIC_DEFAULT_INVIDIOUS_INSTANCE,
|
||||
env.PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE,
|
||||
env.PUBLIC_DEFAULT_API_EXTENDED_INSTANCE,
|
||||
env.PUBLIC_DEFAULT_SYNCIOUS_INSTANCE,
|
||||
env.PUBLIC_DEFAULT_COMPANION_INSTANCE
|
||||
];
|
||||
|
||||
dynamicAllowDomains.forEach((domain) => {
|
||||
if (domain) {
|
||||
allowedDomains.push(domain);
|
||||
}
|
||||
});
|
||||
|
||||
async function proxyRequest(
|
||||
request: Request,
|
||||
urlToProxy: string,
|
||||
captchaKey: string,
|
||||
userId: string | undefined = undefined
|
||||
): Promise<Response> {
|
||||
const backendRestrictions = isOwnBackend();
|
||||
if (!backendRestrictions) {
|
||||
// Shouldn't be possible.
|
||||
throw error(400, 'How did you get here?');
|
||||
}
|
||||
|
||||
if (backendRestrictions.requireAuth && !userId) {
|
||||
throw error(401, 'Auth required');
|
||||
}
|
||||
|
||||
let urlToProxyObj: URL;
|
||||
try {
|
||||
urlToProxyObj = new URL(decodeURIComponent(urlToProxy));
|
||||
} catch {
|
||||
throw error(400, 'Invalid URL');
|
||||
}
|
||||
|
||||
if (!allowedDomains.includes(psl.parse(urlToProxyObj.host).domain)) {
|
||||
// allowAnyProxy allows a instance owner to bypass the whitelist.
|
||||
// BUT is extremely strict.
|
||||
// AND I still don't recommend this.
|
||||
if (
|
||||
!backendRestrictions.allowAnyProxy ||
|
||||
!backendRestrictions.requireAuth ||
|
||||
backendRestrictions.registrationAllowed ||
|
||||
!userId
|
||||
) {
|
||||
throw error(400, 'Invalid URL');
|
||||
}
|
||||
}
|
||||
|
||||
if (urlToProxyObj.pathname.includes('v1/player')) {
|
||||
urlToProxyObj.searchParams.set(
|
||||
'$fields',
|
||||
'playerConfig,captions,playabilityStatus,streamingData,responseContext.mainAppWebResponseContext.datasyncId,videoDetails.isLive,videoDetails.isLiveContent,videoDetails.title,videoDetails.author,playbackTracking'
|
||||
);
|
||||
}
|
||||
|
||||
const requestHeaders = request.headers;
|
||||
requestHeaders.set('host', urlToProxyObj.host);
|
||||
requestHeaders.set('origin', urlToProxyObj.origin);
|
||||
|
||||
for (const key of [
|
||||
'referer',
|
||||
'x-forwarded-for',
|
||||
'x-requested-with',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-platform'
|
||||
]) {
|
||||
requestHeaders.delete(key);
|
||||
}
|
||||
|
||||
const cookieHeader = requestHeaders.get('cookie');
|
||||
if (cookieHeader) {
|
||||
const modifiedCookies = cookieHeader
|
||||
.split(';')
|
||||
.filter((cookie) => !cookie.trim().startsWith('userid='))
|
||||
.join('; ');
|
||||
requestHeaders.set('cookie', modifiedCookies);
|
||||
}
|
||||
|
||||
const fetchRes = await fetch(urlToProxy.toString(), {
|
||||
method: request.method,
|
||||
headers: requestHeaders,
|
||||
body: request.body,
|
||||
credentials: 'same-origin',
|
||||
...(request.body ? { duplex: 'half' } : {})
|
||||
});
|
||||
|
||||
return new Response(fetchRes.body, {
|
||||
status: fetchRes.status,
|
||||
statusText: fetchRes.statusText,
|
||||
...(request.body ? { duplex: 'half' } : {})
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET({ request, params, locals }) {
|
||||
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
|
||||
}
|
||||
export async function PATCH({ request, params, locals }) {
|
||||
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params, locals }) {
|
||||
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
|
||||
}
|
||||
|
||||
export async function PUT({ request, params, locals }) {
|
||||
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
|
||||
}
|
||||
|
||||
export async function POST({ request, params, locals }) {
|
||||
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getUser } from '$lib/server/user';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
|
||||
export async function GET({ params }) {
|
||||
if (!isOwnBackend()?.internalAuth) {
|
||||
return new Response('', { status: 500 });
|
||||
}
|
||||
|
||||
const user = await getUser(params.userId);
|
||||
return json(user.publicPasswordSalts);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import { createUser } from '$lib/server/user';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import z from 'zod';
|
||||
import { setAuthCookie } from '$lib/server/misc';
|
||||
import { verifyCaptcha } from '$lib/server/captcha';
|
||||
|
||||
const zUserCreate = z.object({
|
||||
username: z.string().min(3).max(18),
|
||||
password: z.object({
|
||||
hash: z.string().max(255),
|
||||
salt: z.string().max(255)
|
||||
}),
|
||||
decryptionKeySalt: z.string().max(255),
|
||||
masterKey: z.object({
|
||||
cipher: z.string().max(255),
|
||||
nonce: z.string().max(255)
|
||||
}),
|
||||
captchaPayload: z.string()
|
||||
});
|
||||
|
||||
export async function POST({ request, cookies, locals }) {
|
||||
if (!isOwnBackend()?.internalAuth || !isOwnBackend()?.registrationAllowed) {
|
||||
throw error(500);
|
||||
}
|
||||
|
||||
const userToCreate = zUserCreate.safeParse(await request.json());
|
||||
|
||||
if (!userToCreate.success) throw error(400);
|
||||
|
||||
await verifyCaptcha(userToCreate.data.captchaPayload, locals.captchaKey, 1);
|
||||
|
||||
const createdUser = await createUser({
|
||||
username: userToCreate.data.username,
|
||||
password: {
|
||||
hash: userToCreate.data.password.hash,
|
||||
salt: userToCreate.data.password.salt
|
||||
},
|
||||
decryptionKeySalt: userToCreate.data.decryptionKeySalt,
|
||||
masterKey: {
|
||||
cipher: userToCreate.data.masterKey.cipher,
|
||||
nonce: userToCreate.data.masterKey.nonce
|
||||
}
|
||||
});
|
||||
|
||||
setAuthCookie(createdUser.id, cookies);
|
||||
|
||||
return new Response('');
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getUser } from '$lib/server/user';
|
||||
|
||||
export async function DELETE({ locals }) {
|
||||
const user = await getUser(locals.userId);
|
||||
await user.delete();
|
||||
|
||||
return new Response();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getUser } from '$lib/server/user';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export async function GET({ locals }) {
|
||||
if (!locals.userId) {
|
||||
throw error(401);
|
||||
}
|
||||
|
||||
await getUser(locals.userId);
|
||||
|
||||
return new Response();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { authenticateUser } from '$lib/server/user';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import z from 'zod';
|
||||
import { isOwnBackend } from '$lib/shared';
|
||||
import { setAuthCookie } from '$lib/server/misc';
|
||||
import { verifyCaptcha } from '$lib/server/captcha';
|
||||
|
||||
const zUserLogin = z.object({
|
||||
username: z.string(),
|
||||
passwordHash: z.string(),
|
||||
captchaPayload: z.string()
|
||||
});
|
||||
|
||||
export async function POST({ request, cookies, locals }) {
|
||||
if (!isOwnBackend()?.internalAuth) {
|
||||
throw error(500);
|
||||
}
|
||||
|
||||
const userLogin = zUserLogin.safeParse(await request.json());
|
||||
|
||||
if (!userLogin.success) throw error(401);
|
||||
|
||||
await verifyCaptcha(userLogin.data.captchaPayload, locals.captchaKey, 1);
|
||||
|
||||
const userModel = await authenticateUser(userLogin.data.username, userLogin.data.passwordHash);
|
||||
|
||||
setAuthCookie(userModel.id, cookies);
|
||||
|
||||
return json({
|
||||
masterKeyCipher: userModel.data.masterKeyCipher,
|
||||
masterKeyNonce: userModel.data.masterKeyNonce
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function DELETE({ cookies }) {
|
||||
cookies.delete('userid', { path: '/' });
|
||||
|
||||
return new Response();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getUser } from '$lib/server/user';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export async function GET({ locals }) {
|
||||
const user = await getUser(locals.userId);
|
||||
|
||||
return json({
|
||||
subscriptions: await user.subscriptions()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getUser } from '$lib/server/user';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import z from 'zod';
|
||||
|
||||
const zSubscriptionCreate = z.object({
|
||||
channelIdCipher: z.string().max(255),
|
||||
channelIdNonce: z.string().max(255),
|
||||
channelNameCipher: z.string().max(255),
|
||||
channelNameNonce: z.string().max(255)
|
||||
});
|
||||
|
||||
export async function POST({ locals, request, params }) {
|
||||
const subscription = zSubscriptionCreate.safeParse(await request.json());
|
||||
|
||||
if (!subscription.success) error(400);
|
||||
|
||||
const user = await getUser(locals.userId);
|
||||
await user.addSubscription({
|
||||
...subscription.data,
|
||||
id: params.id,
|
||||
lastRSSFetch: new Date(0)
|
||||
});
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
export async function PATCH({ locals, params }) {
|
||||
const user = await getUser(locals.userId);
|
||||
await user.subscriptionRssUpdated(params.id);
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
export async function DELETE({ locals, params }) {
|
||||
const user = await getUser(locals.userId);
|
||||
await user.removeSubscription(params.id);
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
export async function GET({ locals, params }) {
|
||||
const user = await getUser(locals.userId);
|
||||
|
||||
return json({
|
||||
amSubscribed: await user.amSubscribed(params.id)
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,22 +1,22 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import adapterStatic from '@sveltejs/adapter-static';
|
||||
import adapterNode from '@sveltejs/adapter-node';
|
||||
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const useSsr = process.env.PUBLIC_BUILD_WITH_BACKEND === 'true';
|
||||
|
||||
const adapter = useSsr
|
||||
? adapterNode({ out: 'build', precompress: true })
|
||||
: adapterStatic({
|
||||
fallback: 'index.html'
|
||||
});
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
// Optional: Set base path for hosted Materialious here
|
||||
//paths: {base: '/materialious'},
|
||||
|
||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
|
||||
adapter: adapter({
|
||||
fallback: 'index.html'
|
||||
})
|
||||
},
|
||||
adapter
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -10,21 +10,12 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"types": [
|
||||
"shaka-player"
|
||||
]
|
||||
"types": ["shaka-player"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte", ".svelte-kit/ambient.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
LATEST_VERSION = "1.14.4"
|
||||
LATEST_VERSION = "1.15.0"
|
||||
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