diff --git a/materialious/src/lib/components/WatchParty.svelte b/materialious/src/lib/components/WatchParty.svelte
index 480672d4..d6705ebe 100644
--- a/materialious/src/lib/components/WatchParty.svelte
+++ b/materialious/src/lib/components/WatchParty.svelte
@@ -1,17 +1,25 @@
+
+
+
+{#if $filterContentListStore}
+ {#each $filterContentListStore as filter, index (index)}
+
+ {filter.type}
+
+
+
+ {/each}
+{/if}
diff --git a/materialious/src/lib/components/settings/Settings.svelte b/materialious/src/lib/components/settings/Settings.svelte
index 73566500..36388a3c 100644
--- a/materialious/src/lib/components/settings/Settings.svelte
+++ b/materialious/src/lib/components/settings/Settings.svelte
@@ -14,6 +14,7 @@
import InternalAccount from './InternalAccount.svelte';
import { Tabs } from 'melt/builders';
import { mergeAttrs } from 'melt';
+ import Filters from './Filters.svelte';
type TabCategories =
| 'interface'
@@ -23,7 +24,8 @@
| 'dearrow'
| 'about'
| 'engine'
- | 'account';
+ | 'account'
+ | 'filters';
const tabCategories: Tabs = new Tabs({
value: 'interface',
@@ -39,6 +41,7 @@
let tabs: { id: TabCategories; 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: 'filters', label: $_('layout.filter.title'), icon: 'filter_alt', component: Filters },
{ id: 'ryd', label: 'Return YT Dislike', icon: 'thumb_down', component: Ryd },
{ id: 'sponsorblock', label: 'Sponsorblock', icon: 'block', component: SponsorBlock },
{
diff --git a/materialious/src/lib/filtering.ts b/materialious/src/lib/filtering/index.ts
similarity index 68%
rename from materialious/src/lib/filtering.ts
rename to materialious/src/lib/filtering/index.ts
index 0133aa88..8a38aba9 100644
--- a/materialious/src/lib/filtering.ts
+++ b/materialious/src/lib/filtering/index.ts
@@ -1,8 +1,10 @@
import { z } from 'zod';
-import type { FeedItem } from './feed';
+import type { FeedItem } from '$lib/feed';
import isSafeRegex from 'safe-regex2';
-import { filterContentListStore } from './store';
+import { filterContentListStore } from '$lib/store';
import { get } from 'svelte/store';
+import { originalFetch } from '$lib/fetchProxy';
+import { ChannelSchema, VideoSchema } from './schemas';
const zFilterOperatorEnum = z.enum([
'equals', // equal to
@@ -13,9 +15,13 @@ const zFilterOperatorEnum = z.enum([
'regex' // regular expression matching
]);
+const allowedFields = new Set([...Object.keys(VideoSchema), ...Object.keys(ChannelSchema)]);
+
// Filter condition schema
const zFilterCondition = z.object({
- field: z.string(), // Field to filter
+ field: z.string().refine((val) => allowedFields.has(val), {
+ message: 'Invalid field'
+ }), // Field to filter
operator: zFilterOperatorEnum, // Operator
value: z.union([
// Value to compare against
@@ -30,14 +36,15 @@ const zFilterCondition = z.object({
// Logical grouping operator
const zFilterGroup = z.object({
conditions: z.array(zFilterCondition), // A list of conditions to apply
- operator: z.enum(['AND', 'OR']).optional() // Logical grouping of conditions
+ operator: z.enum(['AND', 'OR']).optional(), // Logical grouping of conditions
+ type: z.union([z.literal('video'), z.literal('channel')]) // Type of content
});
export const zFilterSchema = z.array(zFilterGroup);
const zFilterRootSchema = z.object({
version: z.literal('v1'),
- for: z.literal('materialious'),
+ createdFor: z.literal('materialious'),
filterBy: zFilterSchema
});
@@ -46,7 +53,13 @@ export function isItemFiltered(item: FeedItem): boolean {
if (!filteredContent) return false;
return filteredContent.every((filterGroup) => {
- return filterGroup.conditions.every((condition) => {
+ if (filterGroup.type !== item.type) {
+ if (filterGroup.type !== 'video' || (item.type !== 'shortVideo' && item.type !== 'stream')) {
+ return false;
+ }
+ }
+
+ const evaluateCondition = (condition: (typeof filterGroup.conditions)[number]): boolean => {
if (!(condition.field in item)) return false;
const fieldValue = item[condition.field as keyof FeedItem];
@@ -54,38 +67,53 @@ export function isItemFiltered(item: FeedItem): boolean {
switch (condition.operator) {
case 'equals':
return fieldValue === condition.value;
+
case 'in':
return Array.isArray(condition.value) && (condition.value as any[]).includes(fieldValue);
+
case 'like':
return (
typeof fieldValue === 'string' &&
typeof condition.value === 'string' &&
fieldValue.includes(condition.value)
);
+
case 'gt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue > condition.value
);
+
case 'lt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue < condition.value
);
+
case 'regex':
if (typeof condition.value !== 'string' || !isSafeRegex(condition.value)) return false;
+
return typeof fieldValue === 'string' && new RegExp(condition.value).test(fieldValue);
+
default:
return false;
}
- });
+ };
+
+ const groupOperator = filterGroup.operator ?? 'AND';
+
+ if (groupOperator === 'OR') {
+ return filterGroup.conditions.some(evaluateCondition);
+ }
+
+ return filterGroup.conditions.every(evaluateCondition);
});
}
export async function loadContentFilterFromURL(url: string) {
- const resp = await fetch(url, { method: 'GET', credentials: 'omit' });
+ const resp = await originalFetch(url, { method: 'GET', credentials: 'omit' });
if (!resp.ok) throw new Error('Response status code');
let respJson;
diff --git a/materialious/src/lib/filtering/schemas.ts b/materialious/src/lib/filtering/schemas.ts
new file mode 100644
index 00000000..e5768412
--- /dev/null
+++ b/materialious/src/lib/filtering/schemas.ts
@@ -0,0 +1,28 @@
+// Not possible to exact from typescript interfaces at runtime.
+
+export const VideoSchema: Record = {
+ videoId: 'string',
+ title: 'string',
+ description: 'string',
+ thumbnailUrl: 'string',
+ duration: 'number',
+ publishedAt: 'string',
+ authorId: 'string',
+ viewCount: 'number',
+ viewCountText: 'string',
+ likeCount: 'number',
+ dislikeCount: 'number',
+ commentCount: 'number',
+ isLive: 'boolean',
+ type: 'string'
+};
+
+export const ChannelSchema: Record = {
+ authorId: 'string',
+ name: 'string',
+ description: 'string',
+ avatarUrl: 'string',
+ bannerUrl: 'string',
+ subscriberCount: 'number',
+ verified: 'boolean'
+};
diff --git a/materialious/src/lib/i18n/locales/en.json b/materialious/src/lib/i18n/locales/en.json
index 689f2675..534400cc 100644
--- a/materialious/src/lib/i18n/locales/en.json
+++ b/materialious/src/lib/i18n/locales/en.json
@@ -92,7 +92,9 @@
"joinRoom": "Join room",
"roomID": "Room ID",
"shareRoom": "Share room",
- "leaveRoom": "Leave room"
+ "leaveRoom": "Leave room",
+ "userJoin": "User joined room",
+ "userLeft": "User left room"
},
"subscriptions": {
"manageSubscriptions": "Manage subscriptions"
@@ -257,6 +259,10 @@
"manual": "Manual skip",
"timeline": "Timeline only"
},
+ "filter": {
+ "title": "Content Filters",
+ "url": "Remote filter list"
+ },
"deArrow": {
"title": "DeArrow",
"thumbnailInstanceUrl": "Thumbnail instance URL",
diff --git a/materialious/src/lib/store.ts b/materialious/src/lib/store.ts
index 63e5e3c5..2b209f87 100644
--- a/materialious/src/lib/store.ts
+++ b/materialious/src/lib/store.ts
@@ -23,7 +23,7 @@ import type { ParsedDescription } from './description';
import { ensureNoTrailingSlash, getPublicEnv } from './misc';
import type { EngineFallback } from './api/misc';
import type z from 'zod';
-import type { zFilterSchema } from './filtering';
+import type { zFilterSchema } from './filtering/index';
function createListenerFunctions(): {
callListeners: (eventKey: string, newValue: any) => void;
diff --git a/materialious/src/routes/(app)/+layout.svelte b/materialious/src/routes/(app)/+layout.svelte
index 20b5b4c1..7f83554b 100644
--- a/materialious/src/routes/(app)/+layout.svelte
+++ b/materialious/src/routes/(app)/+layout.svelte
@@ -42,7 +42,7 @@
let mobileSearchShow = $state(false);
let notifications: Notification[] = $state([]);
let playerIsPip = $state(false);
- let showWatchParty = $state(false);
+ let showWatchParty = $state(page.url.searchParams.get('room') !== null);
let pages = $state(getPages());
invidiousAuthStore.subscribe(() => {
@@ -252,6 +252,7 @@