More progress on content filters
This commit is contained in:
@@ -1,17 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { pushState } from '$app/navigation';
|
||||
import { goto, pushState } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { playerState } from '$lib/store';
|
||||
import sodium from 'libsodium-wrappers-sumo';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { joinRoom, type ActionReceiver, type DataPayload, type Room } from 'trystero/mqtt';
|
||||
import z from 'zod';
|
||||
import { addToast } from './Toast.svelte';
|
||||
|
||||
const zWatchPartyEvent = z.object({
|
||||
event: z.union([z.literal('pause'), z.literal('play'), z.literal('seek')]),
|
||||
videoId: z.regex(/^[a-zA-Z0-9_-]{11}$/),
|
||||
event: z.union([
|
||||
z.literal('pause'),
|
||||
z.literal('play'),
|
||||
z.literal('seek'),
|
||||
z.literal('goToVideo')
|
||||
]),
|
||||
videoId: z.string().regex(/^[a-zA-Z0-9_-]{11}$/),
|
||||
sent: z.date(),
|
||||
currentTime: z.number().min(0)
|
||||
});
|
||||
@@ -23,7 +31,7 @@
|
||||
|
||||
const appId = 'materialious_';
|
||||
|
||||
type SendEvent = (message: WatchPartyEvent) => void;
|
||||
type SendEvent = (message: WatchPartyEvent, peerToSendTo?: string) => void;
|
||||
|
||||
// If room not in use message just get sent nowhere.
|
||||
let sendEvent: SendEvent = () => {};
|
||||
@@ -37,6 +45,11 @@
|
||||
const dataParsed = zWatchPartyEvent.safeParse(data);
|
||||
if (!dataParsed.success) return;
|
||||
|
||||
if (dataParsed.data.event === 'goToVideo') {
|
||||
goto(resolve('/watch/[videoId]', { videoId: dataParsed.data.videoId }));
|
||||
return;
|
||||
}
|
||||
|
||||
const player = $playerState;
|
||||
if (!player?.playerElement) return;
|
||||
|
||||
@@ -89,6 +102,35 @@
|
||||
|
||||
currentSearchParams.set('room', roomId);
|
||||
|
||||
room.onPeerJoin((peerId) => {
|
||||
const player = get(playerState);
|
||||
if (!player || !player.data || !player.playerElement) return;
|
||||
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('watchParty.userJoin')
|
||||
}
|
||||
});
|
||||
|
||||
sendEvent(
|
||||
{
|
||||
event: 'goToVideo',
|
||||
videoId: player.data.video.videoId,
|
||||
sent: new Date(),
|
||||
currentTime: player.playerElement.currentTime
|
||||
},
|
||||
peerId
|
||||
);
|
||||
});
|
||||
|
||||
room.onPeerLeave(() => {
|
||||
addToast({
|
||||
data: {
|
||||
text: $_('watchParty.userLeft')
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
pushState(`?${currentSearchParams.toString()}`, { replaceState: false }); // eslint-disable-line svelte/no-navigation-without-resolve
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { SpatialMenu } from 'melt/builders';
|
||||
import { mergeAttrs } from 'melt';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { isItemFiltered } from '$lib/filtering';
|
||||
import { isItemFiltered } from '$lib/filtering/index';
|
||||
|
||||
interface Props {
|
||||
items?: FeedItems;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { loadContentFilterFromURL } from '$lib/filtering';
|
||||
import { _ } from '$lib/i18n';
|
||||
import { filterContentListStore } from '$lib/store';
|
||||
|
||||
let remoteFilterListUrl: string = $state('');
|
||||
let remoteError: string = $state('');
|
||||
|
||||
async function loadFilterList(event: Event) {
|
||||
event.preventDefault();
|
||||
|
||||
remoteError = '';
|
||||
|
||||
if (!remoteFilterListUrl) {
|
||||
remoteError = 'No URL specified';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadContentFilterFromURL(remoteFilterListUrl);
|
||||
} catch (errorMsg) {
|
||||
remoteError = (errorMsg as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={loadFilterList}>
|
||||
<nav>
|
||||
<div
|
||||
class="field prefix label surface-container-highest max"
|
||||
class:invalid={remoteError !== ''}
|
||||
>
|
||||
<i>link</i>
|
||||
<input tabindex="0" bind:value={remoteFilterListUrl} name="remote-url" type="text" />
|
||||
<label tabindex="-1" for="remote-url">{$_('layout.filter.url')}</label>
|
||||
{#if remoteError !== ''}
|
||||
<span class="error">{remoteError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="circle">
|
||||
<i>done</i>
|
||||
</button>
|
||||
</nav>
|
||||
</form>
|
||||
|
||||
{#if $filterContentListStore}
|
||||
{#each $filterContentListStore as filter, index (index)}
|
||||
<article class="no-margin">
|
||||
<h6>{filter.type}</h6>
|
||||
<ul class="list">
|
||||
{#each filter.conditions as condition, index (index)}
|
||||
<li>
|
||||
<p>
|
||||
<code>{condition.field}</code>
|
||||
<b>{condition.operator.replace('gt', 'greater then').replace('lt', 'less then')}</b>
|
||||
<code>{condition.value}</code>
|
||||
</p>
|
||||
</li>
|
||||
{#if index < filter.conditions.length - 1}
|
||||
<li>
|
||||
<p>{filter.operator}</p>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</article>
|
||||
<div class="small-space"></div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -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<TabCategories> = 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 },
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,28 @@
|
||||
// Not possible to exact from typescript interfaces at runtime.
|
||||
|
||||
export const VideoSchema: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
authorId: 'string',
|
||||
name: 'string',
|
||||
description: 'string',
|
||||
avatarUrl: 'string',
|
||||
bannerUrl: 'string',
|
||||
subscriberCount: 'number',
|
||||
verified: 'boolean'
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
<button
|
||||
onclick={() => (showWatchParty = !showWatchParty)}
|
||||
class="circle large transparent"
|
||||
class:active={showWatchParty}
|
||||
>
|
||||
<i>groups</i>
|
||||
<div class="tooltip bottom">{$_('watchParty.start')}</div>
|
||||
|
||||
Reference in New Issue
Block a user