diff --git a/ui/components/ConditionForm.vue b/ui/components/ConditionForm.vue index 49afd01e..a39dbc3c 100644 --- a/ui/components/ConditionForm.vue +++ b/ui/components/ConditionForm.vue @@ -71,6 +71,11 @@
- - The yt-dlp [--match-filters] filter logic. - - Test filter logic - - + yt-dlp [--match-filters] logic.
diff --git a/ui/utils/ytdlp.ts b/ui/utils/ytdlp.ts new file mode 100644 index 00000000..efbb7ac5 --- /dev/null +++ b/ui/utils/ytdlp.ts @@ -0,0 +1,107 @@ +const NUMBER_RE = /\d+(?:\.\d+)?/; + +function match_str(filterStr: string, dct: Record, incomplete: boolean | Set = false): boolean { + return filterStr + .split(/(? _match_one(filterPart.replace(/\\&/g, '&'), dct, incomplete)); +} + +function lookup_unit_table(unitTable: Record, 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(`^(?${numRe.source})\s*(?${unitsRe})\b`, strict ? '' : 'i'); + + const m = s.match(re); + if (!m || !m.groups) return null; + + const num = parseFloat(m.groups.num.replace(',', '.')); + const mult = unitTable[m.groups.unit]; + + return Math.round(num * mult); +} + +function parse_filesize(s: string | null): number | null { + if (!s) return null; + + const UNIT_TABLE: Record = { + B: 1, bytes: 1, b: 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, + }; + + return lookup_unit_table(UNIT_TABLE, s); +} + +function parse_duration(s: string): number | null { + if (!s.trim()) return null; + + const durationRe = /^(?:(?:(?\d+):)?(?\d+):)?(?\d+):(?\d{1,2})(?[.:]\d+)?Z?$/; + const m = s.match(durationRe); + if (!m || !m.groups) return null; + + 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(':', '.')) + ); +} + +function _match_one(filterPart: string, dct: Record, incomplete: boolean | Set): boolean { + const STRING_OPERATORS: Record = { + '*=': (a: string, v: string) => a.includes(v), + '^=': (a: string, v: string) => a.startsWith(v), + '$=': (a: string, v: string) => a.endsWith(v), + '~=': (a: string, v: string) => new RegExp(v).test(a), + }; + + const COMPARISON_OPERATORS: Record = { + ...STRING_OPERATORS, + '<=': (a: any, b: any) => a <= b, + '<': (a: any, b: any) => a < b, + '>=': (a: any, b: any) => a >= b, + '>': (a: any, b: any) => a > b, + '=': (a: any, b: any) => a == b, + }; + + const match = filterPart.trim().match(/^([a-z_]+)\s*(!)?\s*([*^$~=<>]+)\s*(?:"(.+)"|'(.+)'|(.+))$/); + + if (match) { + const [, key, negation, op, qstr, sstr, ustr] = match; + const value = qstr || sstr || ustr; + const actual = dct[key]; + + const compValue = parse_filesize(value) || parse_duration(value) || value; + + const _cmp = COMPARISON_OPERATORS[op]; + if (actual === undefined) return incomplete === true || (incomplete instanceof Set && incomplete.has(key)); + + return negation ? !_cmp(actual, compValue) : _cmp(actual, compValue); + } + + const UNARY_OPERATORS: Record = { + '': (v: any) => v != null, + '!': (v: any) => v == null, + }; + + const unaryMatch = filterPart.trim().match(/^(!)?([a-z_]+)$/); + + if (unaryMatch) { + const [, op, key] = unaryMatch; + const actual = dct[key]; + + if (actual === undefined && (incomplete === true || (incomplete instanceof Set && incomplete.has(key)))) return true; + + return UNARY_OPERATORS[op || ''](actual); + } + + throw new Error(`Invalid filter part '${filterPart}'`); +} + + +export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one }