Merge pull request #1538 from Materialious/update/1.16.13

Update/1.16.13
This commit is contained in:
Ward
2026-03-08 00:12:30 +13:00
committed by GitHub
18 changed files with 387 additions and 155 deletions
+64
View File
@@ -0,0 +1,64 @@
# What is Content filters?
Content filters allows you to conditionally filter content on Materialious based off attributes belonging to Channels & Videos. This allows you to hide content you find offensive, dislike or want to avoid.
# What is "Import Filters from URL"
Materialious allows you to import content filters from other sites, this can be powerful tool as you can write scripts what follows Materialious schema allowing you to generate massive content filters.
Enabling "Automatically update filter from URL" will mean Materialious automatically pulls any changes to the filter list from the provided URL.
# Understanding content filters
## Filter/Content type
Each filter represents a content type ("Video" or "Channel".) This content type dictates what attributes are available like author id, video id etc...
Each content type has one or more conditionals. These conditionals define what attributes must equal in order to filter a piece of content.
## Conditionals
### Field
This represents the attribute of the content type.
### Operators
- `equals` - Value must match exactly.
- `in` - Value is in an array.
- `like` - Value is in the string, e.g. "XYZ" is in "Channel XYZ".
- `gt` - Greater then value.
- `lt` - Less then value.
- `regex` - Define regex expression.
#### Value
Each conditional can have multiple values, each value acts as an "OR" operator. So if one value matches then the piece of content is filtered.
# Writing our schema with JSON (Advanced)
```json
{
"version": "v2", // Must be present
"createdFor": "materialious", // Must be present
"filterBy": [ // Conditionals to filter by
{
"conditions": [
{
"operator": "equals", // Exact match
"field": "authorId", // authorId attribute from video content type
"values": [
"UCH-_hzb2ILSCo9ftVSnrCIQ", // Filter this video ID
"UCLqH-U2TXzj1h7lyYQZLNQQ" // or this video ID.
]
}
],
"type": "video" // The content type
},
{
"conditions": [
{
"operator": "equals",
"field": "authorId",
"values": [
"UCH-_hzb2ILSCo9ftVSnrCIQ"
]
}
],
"type": "channel"
}
]
}
```
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 229
versionName "1.16.12"
versionCode 230
versionName "1.16.13"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -71,7 +71,11 @@
</content_rating>
<releases>
<release version="1.16.12" date="2026-3-06">
<release version="1.16.13" date="2026-3-07">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.13</url>
</release>
<release version="1.16.12" date="2026-3-06">
<url>https://github.com/Materialious/Materialious/releases/tag/1.16.12</url>
</release>
<release version="1.16.11" date="2026-3-06">
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.16.12",
"version": "1.16.13",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.16.12",
"version": "1.16.13",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
@@ -101,4 +101,4 @@
"youtubei.js": "^16.0.1",
"zod": "^4.3.6"
}
}
}
@@ -1,3 +1,4 @@
import { associateAvatar } from '$lib/thumbnail';
import type { ChannelContent, ChannelOptions, ChannelPage } from '../model';
import { buildPath, fetchErrorHandle } from './request';
@@ -8,7 +9,13 @@ export async function getChannelInvidious(
const resp = await fetchErrorHandle(
await fetch(buildPath(`channels/${channelId}`), fetchOptions)
);
return await resp.json();
const respJson = await resp.json();
if (resp.ok) {
associateAvatar(channelId, respJson.authorBanners);
}
return respJson;
}
export async function getChannelContentInvidious(
+9 -1
View File
@@ -4,6 +4,7 @@ import { buildPath, fetchErrorHandle } from './request';
import { getVideoYTjs } from '../youtubejs/video';
import { playerYouTubeJsFallback } from '$lib/store';
import { isUnrestrictedPlatform } from '$lib/misc';
import { associateAvatar } from '$lib/thumbnail';
export async function getVideoInvidious(
videoId: string,
@@ -20,5 +21,12 @@ export async function getVideoInvidious(
} else {
await fetchErrorHandle(resp);
}
return await resp.json();
const respJson = await resp.json();
if (resp.ok) {
associateAvatar(respJson.authorId, respJson.authorThumbnails);
}
return respJson;
}
@@ -9,6 +9,7 @@ import type {
Video
} from '../model';
import { invidiousItemSchema } from './schema';
import { associateAvatar } from '$lib/thumbnail';
export async function getChannelYTjs(channelId: string): Promise<ChannelPage> {
const innertube = await getInnertube();
@@ -23,6 +24,7 @@ export async function getChannelYTjs(channelId: string): Promise<ChannelPage> {
innerResults.header.content?.is(YTNodes.PageHeaderView)
) {
authorBanners = innerResults.header.content.banner?.image ?? [];
associateAvatar(channelId, authorBanners);
}
const description = innerResults.metadata.description ?? '';
@@ -17,6 +17,7 @@ import { Utils, YT, YTNodes, Platform, type IGetChallengeResponse } from 'youtub
import { getInnertube } from '.';
import { isUnrestrictedPlatform } from '$lib/misc';
import { webPoTokenMinter } from '$lib/web/youtube/minter';
import { associateAvatar } from '$lib/thumbnail';
Platform.shim.eval = async (
data: Types.BuildScriptResult,
@@ -149,6 +150,8 @@ export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
if (video.basic_info.channel_id) {
const channel = await innertube.getChannel(video.basic_info.channel_id);
authorThumbnails = channel.metadata.avatar as Image[];
associateAvatar(video.basic_info.channel_id, authorThumbnails);
} else {
authorThumbnails = [];
}
@@ -92,7 +92,7 @@
}
});
}}
accept=".json,.opml,.csv"
accept=".json,.opml,.csv,.db"
type="file"
/>
</div>
@@ -11,6 +11,7 @@
import type z from 'zod';
import { addToast } from '../Toast.svelte';
import { Clipboard } from '@capacitor/clipboard';
import { downloadStringAsFile } from '$lib/misc';
let remoteFilterListUrl: string = $state($filterContentUrlStore ?? '');
let remoteError: string = $state('');
@@ -48,17 +49,21 @@
}
}
async function exportAsJSON() {
function filtersAsJSON() {
return JSON.stringify(
{
version: 'v2',
createdFor: 'materialious',
filterBy: contentFilters
},
null,
2
);
}
async function exportToClipboard() {
await Clipboard.write({
string: JSON.stringify(
{
version: 'v1',
createdFor: 'materialious',
filterBy: contentFilters
},
null,
2
)
string: filtersAsJSON()
});
addToast({
@@ -93,6 +98,14 @@
<article class="error-container">
<p>{$_('layout.backendEngine.warning')}</p>
<p>
Need Help? Check out our <a
class="link"
href="https://github.com/Materialious/Materialious/blob/main/docs/CONTENT-FILTERS.md"
target="_blank"
referrerpolicy="no-referrer">guide</a
>.
</p>
</article>
<form onsubmit={loadFilterList}>
@@ -134,9 +147,16 @@
{#if contentFilters}
{#if contentFilters.length > 0}
<button class="surface-container-highest" onclick={exportAsJSON}>
<button class="surface-container-highest" onclick={exportToClipboard}>
<i>content_copy</i>
<span>{$_('copy')}</span>
<span>{$_('layout.export.exportToClipboard')}</span>
</button>
<button
class="surface-container-highest"
onclick={() => downloadStringAsFile(filtersAsJSON(), 'materialious-filters.json')}
>
<i>content_copy</i>
<span>{$_('layout.export.exportToFile')}</span>
</button>
<div class="space"></div>
{/if}
@@ -167,9 +187,9 @@
<hr />
{#if filter.conditions}
<ul class="list">
{#each filter.conditions as condition (condition)}
{#each filter.conditions as condition, conditionIndex (condition)}
<li style="display: block;">
<nav class="right-align no-margin">
<nav class="no-margin">
<button
onclick={() => {
filter.conditions = filter.conditions.filter((item) => condition !== item);
@@ -179,6 +199,7 @@
class="surface-container-highest"
>
<i>close</i>
<span>Delete conditional</span>
</button>
</nav>
@@ -222,66 +243,102 @@
<i>arrow_drop_down</i>
</div>
{#if schema[filter.type][condition.field] === 'boolean'}
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.value = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="boolean-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
<option selected={condition.value === 'true'} value="true">true</option>
<option selected={condition.value === 'false'} value="false">false</option>
</select>
<label for="boolean-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else if Array.isArray(schema[filter.type][condition.field])}
<div class="field label suffix surface-container-highest">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.value = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="array-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
{#each schema[filter.type][condition.field] as value (value)}
<option selected={condition.value === value} {value}
>{camelCaseToHuman(value)}</option
{#each condition.values as conditionValue, index (index)}
<nav>
{#if schema[filter.type][condition.field] === 'boolean'}
<div class="field label suffix surface-container-highest max">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.values[index] = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="boolean-options"
>
{/each}
</select>
<label for="array-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else}
<div class="field label border">
<input
oninput={(event: Event & { currentTarget: HTMLInputElement }) => {
condition.value =
schema[filter.type][condition.field] === 'number'
? Number(event.currentTarget.value)
: event.currentTarget.value;
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
<option selected={conditionValue === 'true'} value="true">true</option>
<option selected={conditionValue === 'false'} value="false">false</option>
</select>
<label for="boolean-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else if Array.isArray(schema[filter.type][condition.field])}
<div class="field label suffix surface-container-highest max">
<select
onchange={(event: Event & { currentTarget: HTMLSelectElement }) => {
condition.values[index] = event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="array-options"
>
<option value="" disabled selected
>{$_('layout.filter.optionPlaceholder')}</option
>
{#each schema[filter.type][condition.field] as value (value)}
<option selected={conditionValue === value} {value}
>{camelCaseToHuman(value)}</option
>
{/each}
</select>
<label for="array-options">Value</label>
<i>arrow_drop_down</i>
</div>
{:else}
<div class="field label surface-container-highest max">
<input
oninput={(event: Event & { currentTarget: HTMLInputElement }) => {
condition.values[index] =
schema[filter.type][condition.field] === 'number'
? Number(event.currentTarget.value)
: event.currentTarget.value;
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="value"
type={schema[filter.type][condition.field] === 'string' ? 'text' : 'number'}
value={conditionValue}
/>
<label for="value">{$_('layout.filter.value')}</label>
</div>
{/if}
<button
onclick={() => {
condition.values = condition.values.filter((item) => conditionValue !== item);
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
name="value"
type={schema[filter.type][condition.field] === 'string' ? 'text' : 'number'}
value={condition.value}
/>
<label for="value">{$_('layout.filter.value')}</label>
</div>
{/if}
class="circle surface-container-highest"
>
<i>close</i>
<div class="tooltip">Delete Value</div>
</button>
</nav>
{#if condition.values.length > 0 && index < condition.values.length - 1}
<h6 class="center-align">OR</h6>
{/if}
{/each}
<div class="space"></div>
<button
onclick={() => {
condition.values.push('');
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
class="surface-container-highest"
>
<i>add</i>
<span>Add OR</span>
</button>
</li>
{#if filter.conditions.length > 0 && conditionIndex < filter.conditions.length - 1}
<h6 class="center-align">AND</h6>
{/if}
{/each}
</ul>
{/if}
@@ -291,8 +348,9 @@
filter.conditions.push({
operator: 'equals',
field: 'author',
value: ''
values: ['']
});
filterContentListStore.set(contentFilters);
filterContentUrlAutoUpdateStore.set(false);
}}
class="surface-container-highest"
@@ -19,6 +19,8 @@
import { queueGetWatchHistory } from '$lib/api/historyPool';
import { page } from '$app/state';
import { getDeArrow, getThumbnailDeArrow } from '$lib/api/dearrow';
import { avatarFromChannelId } from '$lib/thumbnail';
import { mergeAttrs } from 'melt';
interface Props {
video: VideoBase | Video | Notification | PlaylistPageVideo | VideoWatchHistory;
@@ -69,9 +71,21 @@
}
}
const thumbnail = new Avatar({ src: () => thumbnailSrc });
let thumbnailHeight = $state(0);
let thumbnailHTMLElement: HTMLImageElement | undefined = $state();
const thumbnail = new Avatar({
src: () => thumbnailSrc,
onLoadingStatusChange: () => {
if (thumbnailHTMLElement) thumbnailHeight = thumbnailHTMLElement.naturalHeight;
}
});
let authorAvatarSrc = $state('');
const authorAvatar = new Avatar({
src: () => authorAvatarSrc
});
let startedSideways = sideways === true;
function disableSideways() {
if (!startedSideways) return;
@@ -82,6 +96,12 @@
}
onMount(async () => {
if ('authorId' in video) {
avatarFromChannelId(video.authorId).then((url) => {
if (url) authorAvatarSrc = url;
});
}
// Check if sideways should be enabled or disabled.
disableSideways();
@@ -113,7 +133,7 @@
onclick={onVideoSelected}
>
<div class="thumbnail-image">
<div class:crop={thumbnailHTMLElement ? thumbnailHTMLElement.naturalHeight > 300 : false}>
<div class:crop={thumbnailHeight > 300}>
<img
class="responsive"
class:watched={progress !== undefined}
@@ -173,39 +193,50 @@
<span class="bold">{letterCase(video.title.trimEnd())}</span>
</a>
<div>
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
href={resolve(`/channel/[authorId]`, { authorId: video.authorId })}
data-sveltekit-preload-data="off"
>{video.author}
</a>
{:else}
<p>{video.author}</p>
<nav>
{#if !sideways}
<img class="circle small" {...authorAvatar.image} alt="Channel profile" />
<button
class="secondary-container"
{...mergeAttrs(authorAvatar.fallback, {
style: 'text-transform: uppercase;border-radius: 2.5rem !important;'
})}>{video.author[0]}</button
>
{/if}
<div>
{#if 'authorId' in video && video.authorId}
<a
tabindex="-1"
class:author={!sideways}
href={resolve(`/channel/[authorId]`, { authorId: video.authorId })}
data-sveltekit-preload-data="off"
>{video.author}
</a>
{:else}
<p>{video.author}</p>
{/if}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
{#if 'promotedBy' in video && video.promotedBy === 'favourited'}
<i>star</i>
{/if}
{#if !('publishedText' in video) && 'viewCountText' in video}
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{$_('views')}
{/if}
{#if 'published' in video}
<div class="max">
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{#if !('publishedText' in video) && 'viewCountText' in video}
{video.published && video.published !== 0
? relativeTimestamp(video.published * 1000, false)
: video.publishedText}
</div>
{/if}
</div>
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{$_('views')}
{/if}
{#if 'published' in video}
<div>
{video.viewCountText ?? cleanNumber(video.viewCount ?? 0)}
{video.published && video.published !== 0
? relativeTimestamp(video.published * 1000, false)
: video.publishedText}
</div>
{/if}
</div>
</nav>
</div>
</div>
</div>
@@ -224,7 +255,7 @@
clip-path: inset(10% 0 10% 0);
display: block;
transform: translateY(-15%);
margin-bottom: -20%;
margin-bottom: -22%;
}
.thumbnail {
+10 -2
View File
@@ -13,19 +13,27 @@ export interface ChannelSubscriptions {
lastRSSFetch: Date;
}
export interface ChannelAvatars {
channelId: string;
avatarUrl: string;
updated: Date;
}
export class MaterialiousDb extends Dexie {
favouriteChannels!: Table<FavouriteChannels>;
channelSubscriptions!: Table<ChannelSubscriptions>;
subscriptionFeed!: Table<Video>;
watchHistory!: Table<VideoWatchHistory>;
channelAvatars!: Table<ChannelAvatars>;
constructor() {
super('materialious');
this.version(3).stores({
this.version(4).stores({
favouriteChannels: 'channelId',
channelSubscriptions: 'channelId',
subscriptionFeed: 'videoId, authorId, published',
watchHistory: 'videoId'
watchHistory: 'videoId',
channelAvatars: 'channelId'
});
}
}
+44 -39
View File
@@ -23,14 +23,17 @@ const zFilterCondition = z.object({
message: 'Invalid field'
}), // Field to filter
operator: zFilterOperatorEnum, // Operator
value: z.union([
// Value to compare against
z.string(),
z.number(),
z.array(z.string()),
z.array(z.number()),
z.string().regex(/.*/)
])
values: z.array(
z.union([
// Value to compare against
z.string(),
z.number(),
z.array(z.string()),
z.array(z.number()),
z.string().regex(/.*/)
])
),
note: z.string().optional()
});
// Logical grouping operator
@@ -42,7 +45,7 @@ export const zFilterGroup = z.object({
export const zFilterSchema = z.array(zFilterGroup);
const zFilterRootSchema = z.object({
version: z.literal('v1'),
version: z.literal('v2'),
createdFor: z.literal('materialious'),
filterBy: zFilterSchema
});
@@ -66,45 +69,47 @@ export function isItemFiltered(item: FeedItem): boolean {
const fieldValue = item[condition.field as keyof FeedItem];
switch (condition.operator) {
case 'equals':
return fieldValue.toString() === condition.value.toString();
return condition.values.some((conditionValue) => {
switch (condition.operator) {
case 'equals':
return fieldValue.toString() === conditionValue.toString();
case 'in':
return Array.isArray(condition.value) && (condition.value as any[]).includes(fieldValue);
case 'in':
return Array.isArray(conditionValue) && (conditionValue as any[]).includes(fieldValue);
case 'like':
return (
typeof fieldValue === 'string' &&
typeof condition.value === 'string' &&
fieldValue.toLowerCase().includes(condition.value.toLowerCase())
);
case 'like':
return (
typeof fieldValue === 'string' &&
typeof conditionValue === 'string' &&
fieldValue.toLowerCase().includes(conditionValue.toLowerCase())
);
case 'gt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue > condition.value
);
case 'gt':
return (
typeof fieldValue === 'number' &&
typeof conditionValue === 'number' &&
fieldValue > conditionValue
);
case 'lt':
return (
typeof fieldValue === 'number' &&
typeof condition.value === 'number' &&
fieldValue < condition.value
);
case 'lt':
return (
typeof fieldValue === 'number' &&
typeof conditionValue === 'number' &&
fieldValue < conditionValue
);
case 'regex':
if (typeof condition.value !== 'string' || !isSafeRegex(condition.value)) return false;
case 'regex':
if (typeof conditionValue !== 'string' || !isSafeRegex(conditionValue)) return false;
return typeof fieldValue === 'string' && new RegExp(condition.value).test(fieldValue);
return typeof fieldValue === 'string' && new RegExp(conditionValue).test(fieldValue);
default:
return false;
}
default:
return false;
}
});
};
return filterGroup.conditions.every(evaluateCondition);
return filterGroup.conditions.some(evaluateCondition);
});
}
+5 -1
View File
@@ -13,7 +13,11 @@ export const VideoSchema: SchemaStructure = {
type: ['video', 'shortVideo', 'stream'],
videoId: 'string',
viewCount: 'number',
viewCountText: 'string'
viewCountText: 'string',
lengthSeconds: 'number',
premiereTimestamp: 'number',
isUpcoming: 'boolean',
premium: 'boolean'
};
export const ChannelSchema: SchemaStructure = {
+38
View File
@@ -0,0 +1,38 @@
import type { Image } from './api/model';
import { localDb } from './dexie';
import { getBestThumbnail } from './images';
const updateDebounce: Record<string, ReturnType<typeof setTimeout>> = {};
let isInitial = false;
export async function associateAvatar(channelId: string, avatars: Image[]) {
if (avatars.length === 0) return;
if ('channelId' in updateDebounce) clearTimeout(updateDebounce[channelId]);
updateDebounce[channelId] = setTimeout(() => {
localDb.channelAvatars.put(
{
channelId: channelId,
avatarUrl: getBestThumbnail(avatars),
updated: new Date()
},
{ channelId: channelId }
);
}, 100);
if (isInitial) {
isInitial = true;
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
localDb.channelAvatars.where('updated').below(oneMonthAgo).delete();
}
}
export async function avatarFromChannelId(channelId: string): Promise<void | string> {
const result = await localDb.channelAvatars.get({ channelId });
if (result) {
return result.avatarUrl;
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.16.12"
LATEST_VERSION = "1.16.13"
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")