ytptube/ui/app/utils/ytdlp.ts
2026-06-14 18:35:03 +03:00

331 lines
9.5 KiB
TypeScript

const NUMBER_RE = /\d+(?:\.\d+)?/;
function lookup_unit_table(
unitTable: Record<string, number>,
s: string,
strict = false,
): number | null {
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
const unitsRe = Object.keys(unitTable)
.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? '' : 'i');
const m = s.match(re);
if (!m?.groups?.num || !m.groups.unit) {
return null;
}
const numStr = m.groups.num.replace(',', '.');
const unit = m.groups.unit;
const mult = unitTable[unit];
if (undefined === mult) return null;
return Math.round(parseFloat(numStr) * mult);
}
function parse_filesize(s: string | null): number | null {
if (!s) return null;
const UNIT_TABLE: Record<string, number> = {
B: 1,
b: 1,
bytes: 1,
KiB: 1024,
KB: 1000,
kB: 1024,
kb: 1000,
kilobytes: 1000,
kibibytes: 1024,
MiB: 1024 ** 2,
MB: 1000 ** 2,
megabytes: 1000 ** 2,
mebibytes: 1024 ** 2,
GiB: 1024 ** 3,
GB: 1000 ** 3,
gigabytes: 1000 ** 3,
gibibytes: 1024 ** 3,
TiB: 1024 ** 4,
TB: 1000 ** 4,
terabytes: 1000 ** 4,
tebibytes: 1024 ** 4,
PiB: 1024 ** 5,
PB: 1000 ** 5,
petabytes: 1000 ** 5,
pebibytes: 1024 ** 5,
};
return lookup_unit_table(UNIT_TABLE, s);
}
function parse_duration(s: string): number | null {
if (!s?.trim()) return null;
// Support ISO-ish short forms: "2m", "10s", "1h", "1d"
const simpleRe = /^(?<num>\d+(?:\.\d+)?)(?<unit>[smhd])$/i;
const sm = s.match(simpleRe);
if (sm?.groups?.num && sm.groups.unit) {
const num = parseFloat(sm.groups.num);
switch (sm.groups.unit.toLowerCase()) {
case 's':
return num;
case 'm':
return num * 60;
case 'h':
return num * 3600;
case 'd':
return num * 86400;
}
}
// Support hh:mm:ss[.ms]
const hmsRe =
/^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
const m = s.match(hmsRe);
if (m?.groups) {
const { days, hours, mins, secs, ms } = m.groups;
return (
parseFloat(days ?? '0') * 86400 +
parseFloat(hours ?? '0') * 3600 +
parseFloat(mins ?? '0') * 60 +
parseFloat(secs ?? '0') +
parseFloat((ms ?? '0').replace(':', '.'))
);
}
return null;
}
// ---------- Match One ----------
function _match_one(filterPart: string, dct: Record<string, any>): boolean {
const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = {
'*=': (a, b) => a.includes(b),
'^=': (a, b) => a.startsWith(b),
'$=': (a, b) => a.endsWith(b),
'~=': (a, b) => new RegExp(b).test(a),
};
const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
'*=': STRING_OPERATORS['*=']!,
'^=': STRING_OPERATORS['^=']!,
'$=': STRING_OPERATORS['$=']!,
'~=': STRING_OPERATORS['~=']!,
'<=': (a, b) => a <= b,
'<': (a, b) => a < b,
'>=': (a, b) => a >= b,
'>': (a, b) => a > b,
'=': (a, b) => a == b,
};
const UNARY_OPERATORS: Record<string, (v: any) => boolean> = {
'': (v) => (typeof v === 'boolean' ? v === true : v != null),
'!': (v) => (typeof v === 'boolean' ? v === false : v == null),
};
const cmpOps = Object.keys(COMPARISON_OPERATORS)
.map((op) => op.replace(/([.*+?^${}()|[\]\\])/g, '\\$1'))
.join('|');
const operatorRe = new RegExp(
`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+?))?$`,
);
const m = filterPart.trim().match(operatorRe);
if (m?.groups) {
const { key, negation, op, quote, quoted, plain } = m.groups;
const unnegatedOp = COMPARISON_OPERATORS[op!];
if (!unnegatedOp) throw new Error(`Invalid operator '${op}'`);
// Handle incomplete expressions (missing value)
const value = quoted ?? plain ?? '';
if (!value && op !== '=' && op !== '!') {
return false; // Incomplete non-equality expression
}
const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
let processedValue = value;
if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
const actual = dct[key!];
if (actual === undefined || actual === null) {
return false;
}
let numeric: number | null = Number(processedValue);
if (Number.isNaN(numeric)) {
numeric =
parse_filesize(processedValue) ??
parse_filesize(`${processedValue}B`) ??
parse_duration(processedValue);
}
// Also try to convert actual value to numeric if we have a numeric comparison
let finalActual = actual;
if (numeric !== null && typeof actual === 'string') {
const numericActual = Number(actual);
if (!Number.isNaN(numericActual)) {
finalActual = numericActual;
} else {
const parsedActual = parse_filesize(actual) ?? parse_duration(actual);
if (parsedActual !== null) {
finalActual = parsedActual;
}
}
}
if (numeric !== null && op! in STRING_OPERATORS) {
throw new Error(`Operator ${op} only supports string values!`);
}
return opFn(finalActual, numeric !== null ? numeric : processedValue);
}
// unary operator
const unaryRe = /^(?<op>!?)\s*(?<key>[a-z_]+)$/;
const mu = filterPart.trim().match(unaryRe);
if (mu?.groups) {
const { op, key } = mu.groups;
const actual = dct[key!];
return UNARY_OPERATORS[op!]!(actual);
}
throw new Error(`Invalid filter part '${filterPart}'`);
}
// ---------- Parser ----------
type Node = ['ATOM', string] | ['AND', Node, Node] | ['OR', Node, Node];
class MatchFilterParser {
expr: string;
tokens: [string, string][];
pos: number;
ast: Node;
constructor(expr: string) {
this.expr = expr;
this.tokens = this._tokenize(expr);
this.pos = 0;
this.ast = this._parseOr();
}
static run(expr: string, dct: Record<string, any> = {}): boolean {
return new MatchFilterParser(expr).evaluate(dct);
}
evaluate(dct: Record<string, any>): boolean {
return this._eval(this.ast, dct);
}
export(): string[] {
return this._export(this.ast).map((parts) => parts.join('&'));
}
private _tokenize(expr: string): [string, string][] {
// First, let's normalize spaces around operators to make parsing easier
// Replace spaced operators with non-spaced ones
let normalizedExpr = expr;
const operators = ['<=', '>=', '<', '>', '\\*=', '\\^=', '\\$=', '~=', '='];
for (const op of operators) {
// Replace "key op value" with "keyopvalue" (removing extra spaces)
const pattern = new RegExp(`([a-z_]+)\\s*(!?)\\s*(${op})\\s*`, 'g');
normalizedExpr = normalizedExpr.replace(pattern, '$1$2$3');
}
const tokenSpec: [RegExp, string | null][] = [
[/\|\|/, 'OR'],
[/&/, 'AND'],
[/\(/, 'LPAREN'],
[/\)/, 'RPAREN'],
[/[^\s&|()]+/, 'ATOM'],
[/\s+/, null],
];
const regex = new RegExp(
tokenSpec.map(([pat, _name], i) => `(?<T${i}>${pat.source})`).join('|'),
'g',
);
const tokens: [string, string][] = [];
let m: RegExpExecArray | null;
while ((m = regex.exec(normalizedExpr)) !== null) {
for (let i = 0; i < tokenSpec.length; i++) {
if (m.groups?.[`T${i}`]) {
const tokenSpecEntry = tokenSpec[i];
if (tokenSpecEntry) {
const kind = tokenSpecEntry[1];
if (kind) tokens.push([kind, m[0]]);
}
break;
}
}
}
return tokens;
}
private _accept(kind: string): boolean {
if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) {
this.pos++;
return true;
}
return false;
}
private _expect(kind: string): [string, string] {
if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) {
return this.tokens[this.pos++]!;
}
throw new SyntaxError(`Expected ${kind}`);
}
private _parseOr(): Node {
let left = this._parseAnd();
while (this._accept('OR')) {
const right = this._parseAnd();
left = ['OR', left, right];
}
return left;
}
private _parseAnd(): Node {
let left = this._parseAtom();
while (this._accept('AND')) {
const right = this._parseAtom();
left = ['AND', left, right];
}
return left;
}
private _parseAtom(): Node {
if (this._accept('LPAREN')) {
const node = this._parseOr();
this._expect('RPAREN');
return node;
}
const tok = this._expect('ATOM');
return ['ATOM', tok[1].trim()];
}
private _eval(node: Node, dct: Record<string, any>): boolean {
if (node[0] === 'ATOM') {
return _match_one(node[1], dct);
}
if (node[0] === 'AND') {
return this._eval(node[1], dct) && this._eval(node[2], dct);
}
if (node[0] === 'OR') {
return this._eval(node[1], dct) || this._eval(node[2], dct);
}
throw new Error('Invalid AST node ' + node[0]);
}
private _export(node: Node): string[][] {
if (node[0] === 'ATOM') return [[node[1]]];
if (node[0] === 'AND') {
const left = this._export(node[1]);
const right = this._export(node[2]);
return left.flatMap((l) => right.map((r) => [...l, ...r]));
}
if (node[0] === 'OR') {
return [...this._export(node[1]), ...this._export(node[2])];
}
throw new Error('Invalid AST node ' + node[0]);
}
}
const match_str = (expr: string, dct: Record<string, any>): boolean =>
MatchFilterParser.run(expr, dct);
export { MatchFilterParser, match_str };