Add sort and exclusion filters to the media grid

This commit is contained in:
Fisto
2026-09-17 11:01:50 +02:00
parent cc14140f34
commit 515af01e84
13 changed files with 561 additions and 96 deletions
+33
View File
@@ -887,6 +887,23 @@ function buildServer(): McpServer {
cachedOnly: z.boolean().optional().describe("Only items with a cached preview"),
exts: z.array(z.string()).optional().describe("Only these file extensions (mp4, webm, png, ...)"),
excludeHosts: z.array(z.string()).optional().describe("Hide URLs from these hosts"),
excludeExts: z.array(z.string()).optional().describe("Hide these file extensions"),
excludeProviders: z
.array(z.string())
.optional()
.describe("Hide these providers (youtube, twitch, discord, tenor, giphy)"),
excludeSources: z
.array(z.string())
.optional()
.describe("Hide these row sources (attachment, sticker, embed)"),
sizeLessThan: z.number().optional().describe("Only items smaller than this many bytes"),
sizeGreaterThan: z.number().optional().describe("Only items larger than this many bytes"),
lengthLessThan: z.number().optional().describe("Only items shorter than this many seconds"),
lengthGreaterThan: z.number().optional().describe("Only items longer than this many seconds"),
sort: z
.enum(["newest", "oldest", "user", "size", "length"])
.optional()
.describe("Order: newest (default), oldest, user, size, length"),
aspect: z
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
.optional()
@@ -911,6 +928,14 @@ function buildServer(): McpServer {
cachedOnly,
exts,
excludeHosts,
excludeExts,
excludeProviders,
excludeSources,
sizeLessThan,
sizeGreaterThan,
lengthLessThan,
lengthGreaterThan,
sort,
aspect,
resolution,
after,
@@ -929,6 +954,14 @@ function buildServer(): McpServer {
cachedOnly,
exts,
excludeHosts,
excludeExts,
excludeProviders,
excludeSources,
sizeLessThan,
sizeGreaterThan,
lengthLessThan,
lengthGreaterThan,
sort,
aspect,
resolution,
after,
+34 -1
View File
@@ -10,6 +10,7 @@ import { isAbsolute, join } from "node:path";
import {
BackfillQueue,
betterCandidate,
sortCandidates,
dirSizeBytes,
isNearLimit,
planRetention,
@@ -22,6 +23,7 @@ import {
type MediaRow,
type BackfillSettings,
type BackfillStatus,
type MediaSort,
type ScrapeBackend,
type ScrapeMeta
} from "../../media-scrape/src/index.js";
@@ -708,6 +710,14 @@ export class MediaService {
cachedOnly?: boolean;
exts?: string[];
excludeHosts?: string[];
excludeExts?: string[];
excludeProviders?: string[];
excludeSources?: string[];
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
lengthGreaterThan?: number;
sort?: MediaSort;
aspect?: string;
resolution?: string;
after?: string;
@@ -829,10 +839,18 @@ export class MediaService {
cachedOnly?: boolean;
exts?: string[];
excludeHosts?: string[];
excludeExts?: string[];
excludeProviders?: string[];
excludeSources?: string[];
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
lengthGreaterThan?: number;
aspect?: string;
resolution?: string;
after?: string;
before?: string;
sort?: MediaSort;
}): Array<{ meta: ScrapeMeta; row: MediaRow }> {
const candidates = new Map<string, { meta: ScrapeMeta; row: MediaRow }>();
@@ -856,6 +874,13 @@ export class MediaService {
cachedOnly: options.cachedOnly,
exts: options.exts,
excludeHosts: options.excludeHosts,
excludeExts: options.excludeExts,
excludeProviders: options.excludeProviders,
excludeSources: options.excludeSources,
sizeLessThan: options.sizeLessThan,
sizeGreaterThan: options.sizeGreaterThan,
lengthLessThan: options.lengthLessThan,
lengthGreaterThan: options.lengthGreaterThan,
aspect: options.aspect,
resolution: options.resolution,
after: options.after,
@@ -893,7 +918,7 @@ export class MediaService {
}
}
return [...candidates.values()];
return sortCandidates([...candidates.values()], options.sort ?? "newest");
}
/** Read media rows across every scrape, richest per URL, newest scrape first. */
@@ -909,6 +934,14 @@ export class MediaService {
cachedOnly?: boolean;
exts?: string[];
excludeHosts?: string[];
excludeExts?: string[];
excludeProviders?: string[];
excludeSources?: string[];
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
lengthGreaterThan?: number;
sort?: MediaSort;
aspect?: string;
resolution?: string;
after?: string;
+51 -83
View File
@@ -177,6 +177,54 @@ function startAudioSocket(server: http.Server | https.Server, deps: WebUiDeps):
});
}
/** Shared parsing for the media grid filters, used by /api/media and /api/media/stats. */
function parseMediaFilters(query: Record<string, unknown>) {
const list = (value: unknown) =>
value
? String(value)
.split(",")
.map(entry => entry.trim().toLowerCase())
.filter(Boolean)
: undefined;
const amount = (value: unknown) => {
if (value === undefined || value === "") return undefined;
const number = Number(value);
return Number.isFinite(number) && number >= 0 ? number : undefined;
};
return {
kind: ["image", "gif", "video", "audio"].includes(String(query.kind ?? ""))
? (String(query.kind) as "image" | "gif" | "video" | "audio")
: undefined,
source: ["attachment", "sticker", "embed"].includes(String(query.source ?? ""))
? String(query.source)
: undefined,
channelId: query.channelId ? String(query.channelId) : undefined,
authorIds: list(query.authorId),
origin:
query.origin === "dm" || query.origin === "guild"
? (String(query.origin) as "dm" | "guild")
: undefined,
cachedOnly: query.cachedOnly === "1" || query.cachedOnly === "true",
exts: list(query.ext),
excludeHosts: list(query.excludeHosts),
excludeExts: list(query.excludeExt),
excludeProviders: list(query.excludeProviders),
excludeSources: list(query.excludeSources),
sizeLessThan: amount(query.sizeLt),
sizeGreaterThan: amount(query.sizeGt),
lengthLessThan: amount(query.lenLt),
lengthGreaterThan: amount(query.lenGt),
aspect: query.aspect ? String(query.aspect) : undefined,
resolution: query.resolution ? String(query.resolution) : undefined,
after: query.after ? String(query.after) : undefined,
before: query.before ? String(query.before) : undefined,
sort: ["newest", "oldest", "user", "size", "length"].includes(String(query.sort ?? ""))
? (String(query.sort) as "newest" | "oldest" | "user" | "size" | "length")
: undefined
};
}
export function startWebUi(deps: WebUiDeps): WebUiHandle {
const app = express();
app.use(express.json({ limit: "64kb" }));
@@ -901,58 +949,14 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
app.get(
"/api/media",
wrap(async (req, res) => {
const kind = ["image", "gif", "video", "audio"].includes(String(req.query.kind ?? ""))
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
: undefined;
const source = ["attachment", "sticker", "embed"].includes(String(req.query.source ?? ""))
? String(req.query.source)
: undefined;
const channelId = req.query.channelId ? String(req.query.channelId) : undefined;
const authorIds = req.query.authorId
? String(req.query.authorId)
.split(",")
.map(value => value.trim())
.filter(Boolean)
: undefined;
const origin =
req.query.origin === "dm" || req.query.origin === "guild"
? (String(req.query.origin) as "dm" | "guild")
: undefined;
const cachedOnly = req.query.cachedOnly === "1" || req.query.cachedOnly === "true";
const exts = req.query.ext
? String(req.query.ext)
.split(",")
.map(value => value.trim().toLowerCase())
.filter(Boolean)
: undefined;
const excludeHosts = req.query.excludeHosts
? String(req.query.excludeHosts)
.split(",")
.map(value => value.trim().toLowerCase())
.filter(Boolean)
: undefined;
const aspect = req.query.aspect ? String(req.query.aspect) : undefined;
const resolution = req.query.resolution ? String(req.query.resolution) : undefined;
const after = req.query.after ? String(req.query.after) : undefined;
const before = req.query.before ? String(req.query.before) : undefined;
const filters = parseMediaFilters(req.query);
const limit = clampLimit(req.query.limit, 60, 500);
const offset = Math.max(0, Number(req.query.offset ?? 0) || 0);
res.json({
ok: true,
result: deps.media.queryMedia({
kind,
source,
channelId,
authorIds,
origin,
cachedOnly,
exts,
excludeHosts,
aspect,
resolution,
after,
before,
...filters,
limit,
offset
})
@@ -970,46 +974,10 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
app.get(
"/api/media/stats",
wrap(async (req, res) => {
const kind = ["image", "gif", "video", "audio"].includes(String(req.query.kind ?? ""))
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
: undefined;
const authorIds = req.query.authorId
? String(req.query.authorId)
.split(",")
.map(value => value.trim())
.filter(Boolean)
: undefined;
const origin =
req.query.origin === "dm" || req.query.origin === "guild"
? (String(req.query.origin) as "dm" | "guild")
: undefined;
const exts = req.query.ext
? String(req.query.ext)
.split(",")
.map(value => value.trim().toLowerCase())
.filter(Boolean)
: undefined;
const excludeHosts = req.query.excludeHosts
? String(req.query.excludeHosts)
.split(",")
.map(value => value.trim().toLowerCase())
.filter(Boolean)
: undefined;
res.json({
ok: true,
result: deps.media.stats({
kind,
source: req.query.source ? String(req.query.source) : undefined,
channelId: req.query.channelId ? String(req.query.channelId) : undefined,
authorIds,
origin,
cachedOnly: req.query.cachedOnly === "1" || req.query.cachedOnly === "true",
exts,
excludeHosts,
aspect: req.query.aspect ? String(req.query.aspect) : undefined,
resolution: req.query.resolution ? String(req.query.resolution) : undefined,
...parseMediaFilters(req.query),
after: req.query.after ? String(req.query.after) : undefined,
before: req.query.before ? String(req.query.before) : undefined
})