Merge pull request #1538 from Materialious/update/1.16.13
Update/1.16.13
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user