From c54df1f0f00ea96e4f28d99493c3af5cccf1621c Mon Sep 17 00:00:00 2001 From: WardPearce Date: Thu, 5 Mar 2026 01:01:52 +1300 Subject: [PATCH] More progress on content filters --- .../src/lib/components/WatchParty.svelte | 50 ++++++++++++-- .../lib/components/layout/ItemsList.svelte | 2 +- .../lib/components/settings/Filters.svelte | 69 +++++++++++++++++++ .../lib/components/settings/Settings.svelte | 5 +- .../lib/{filtering.ts => filtering/index.ts} | 44 +++++++++--- materialious/src/lib/filtering/schemas.ts | 28 ++++++++ materialious/src/lib/i18n/locales/en.json | 8 ++- materialious/src/lib/store.ts | 2 +- materialious/src/routes/(app)/+layout.svelte | 3 +- 9 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 materialious/src/lib/components/settings/Filters.svelte rename materialious/src/lib/{filtering.ts => filtering/index.ts} (68%) create mode 100644 materialious/src/lib/filtering/schemas.ts 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 @@