ytptube/ui/app/utils/ytdlp.ts
2025-09-11 21:49:39 +03:00

304 lines
9.4 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 = null;
// Try to convert comparison value to numeric first
numeric = 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 }