improved match_str by implementing extended subset.

This commit is contained in:
arabcoders 2025-09-10 22:18:16 +03:00
parent a39a009395
commit fae1d7c577
30 changed files with 4758 additions and 251 deletions

View file

@ -12,6 +12,7 @@
], ],
"cSpell.words": [ "cSpell.words": [
"aconvert", "aconvert",
"addopts",
"ahas", "ahas",
"ahash", "ahash",
"aiocron", "aiocron",
@ -26,6 +27,7 @@
"brainicism", "brainicism",
"brotlicffi", "brotlicffi",
"buildcache", "buildcache",
"Cfmrc",
"choco", "choco",
"consoletitle", "consoletitle",
"continuedl", "continuedl",
@ -41,7 +43,9 @@
"dlfields", "dlfields",
"dotenv", "dotenv",
"dpkg", "dpkg",
"Edes",
"edgechromium", "edgechromium",
"EISGPM",
"engineio", "engineio",
"Errno", "Errno",
"esac", "esac",
@ -62,12 +66,14 @@
"httpx", "httpx",
"imagetools", "imagetools",
"kibibytes", "kibibytes",
"lastgroup",
"levelno", "levelno",
"libcurl", "libcurl",
"libjavascriptcoregtk", "libjavascriptcoregtk",
"libstdc", "libstdc",
"libwebkit", "libwebkit",
"libx", "libx",
"LPAREN",
"matchtitle", "matchtitle",
"matroska", "matroska",
"mbed", "mbed",
@ -102,10 +108,12 @@
"pycryptodomex", "pycryptodomex",
"pyinstaller", "pyinstaller",
"pypi", "pypi",
"pythonpath",
"quicktime", "quicktime",
"rejecttitle", "rejecttitle",
"remux", "remux",
"reqs", "reqs",
"RPAREN",
"rtime", "rtime",
"rvfc", "rvfc",
"SIGUSR", "SIGUSR",
@ -115,6 +123,7 @@
"sstr", "sstr",
"SUPPRESSHELP", "SUPPRESSHELP",
"tebibytes", "tebibytes",
"testpaths",
"tiktok", "tiktok",
"timespec", "timespec",
"tmpfilename", "tmpfilename",

View file

@ -16,9 +16,10 @@ from pathlib import Path
from typing import Any, TypeVar from typing import Any, TypeVar
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted, match_str from yt_dlp.utils import age_restricted
from .LogWrapper import LogWrapper from .LogWrapper import LogWrapper
from .mini_filter import match_str
from .ytdlp import YTDLP from .ytdlp import YTDLP
LOG: logging.Logger = logging.getLogger("Utils") LOG: logging.Logger = logging.getLogger("Utils")

View file

@ -10,6 +10,7 @@ from aiohttp import web
from .config import Config from .config import Config
from .encoder import Encoder from .encoder import Encoder
from .Events import EventBus, Events from .Events import EventBus, Events
from .mini_filter import match_str
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import arg_converter, init_class from .Utils import arg_converter, init_class
@ -183,8 +184,6 @@ class Conditions(metaclass=Singleton):
bool: True if valid, False otherwise. bool: True if valid, False otherwise.
""" """
from yt_dlp.utils import match_str
if not isinstance(item, dict): if not isinstance(item, dict):
if not isinstance(item, Condition): if not isinstance(item, Condition):
msg = f"Unexpected '{type(item).__name__}' item type." msg = f"Unexpected '{type(item).__name__}' item type."
@ -306,8 +305,6 @@ class Conditions(metaclass=Singleton):
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1: if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None return None
from yt_dlp.utils import match_str
for item in self.get_all(): for item in self.get_all():
if not item.filter: if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.") LOG.error(f"Filter is empty for '{item.name}'.")
@ -343,6 +340,4 @@ class Conditions(metaclass=Singleton):
if not item or not item.filter: if not item or not item.filter:
return None return None
from yt_dlp.utils import match_str
return item if match_str(item.filter, info) else None return item if match_str(item.filter, info) else None

297
app/library/mini_filter.py Normal file
View file

@ -0,0 +1,297 @@
import operator
import re
from typing import Any
TOKEN = tuple[str, str]
AST_NODE = tuple[str, ...]
def match_str(expr: str, dct: dict) -> bool:
"""
Convenience function to evaluate a filter expression against a dict.
Note: This implementation uses numeric duration/filesize values in tests to avoid
yt-dlp's inconsistent unit parsing behavior. yt-dlp's match_str has known issues
with duration unit formats like "2m" that don't behave consistently with their
numeric equivalents (e.g., "120").
Args:
expr (str): Filter expression string
dct (dict): Dictionary of values to check against
Returns:
bool: True/False if expression matches
"""
return MiniFilter(expr).evaluate(dct)
class MiniFilter:
"""
Parser and evaluator for yt-dlp style match-filters, extended with
support for OR (`||`) and grouping with parentheses.
Features:
- Supports AND (`&`), OR (`||`), and parentheses `()`
- Reuses yt-dlp style operators (=, >=, *=, ~=, etc.)
- Export to yt-dlp compatible `--match-filters`.
"""
# Type aliases for better readability
Token = tuple[str, str]
ASTNode = tuple[str, ...]
# Supported string operators
STRING_OPERATORS: dict[str, callable] = {
"*=": operator.contains,
"^=": lambda attr, value: isinstance(attr, str) and attr.startswith(value),
"$=": lambda attr, value: isinstance(attr, str) and attr.endswith(value),
"~=": lambda attr, value: bool(re.search(value, attr or "")),
}
# Comparison operators (numeric + string)
COMPARISON_OPERATORS: dict[str, callable] = {
**STRING_OPERATORS,
"<=": operator.le,
"<": operator.lt,
">=": operator.ge,
">": operator.gt,
"=": operator.eq,
}
# Unary operators (for presence/absence checks)
UNARY_OPERATORS: dict[str, callable] = {
"": lambda v: (v is True) if isinstance(v, bool) else (v is not None),
"!": lambda v: (v is False) if isinstance(v, bool) else (v is None),
}
def __init__(self, expr: str) -> None:
"""
Initialize a parser for the given filter expression.
:param expr: Filter expression string, e.g. "(duration<10m & filesize>1MB) || uploader*='BBC'"
"""
self.expr: str = expr
self.tokens: list[TOKEN] = self._tokenize(expr)
self.pos: int = 0
self.ast: AST_NODE = self._parse_or()
@staticmethod
def run(expr: str, dct: dict | bool = False) -> bool:
"""
Convenience method to evaluate an expression directly.
:param expr: Filter expression string
:param dct: Dictionary of values to check against
:return: True/False if expression matches
"""
return MiniFilter(expr).evaluate(dct)
def evaluate(self, dct: dict | bool = False) -> bool:
"""
Evaluate the parsed expression against a dictionary.
:param dct: Dictionary of attributes (video metadata, etc.)
:return: True/False result of expression
"""
return self._eval(self.ast, dct)
def export(self) -> list[str]:
"""
Export expression into yt-dlp compatible `--match-filters` strings.
Since yt-dlp does not support OR (`||`) or parentheses,
this method flattens the expression into multiple AND-only strings.
Example:
(a=1 & b=2) || c=3
["a=1&b=2", "c=3"]
:return: List of AND-only filter strings
"""
return ["&".join(parts) for parts in self._export(self.ast)]
def _tokenize(self, expr: str) -> list[TOKEN]:
"""
Split expression into tokens (AND, OR, LPAREN, RPAREN, ATOM).
Ensures that OR (||) and AND (&) are recognized before ATOM.
"""
# First, let's normalize spaces around operators to make parsing easier
# Replace spaced operators with non-spaced ones
normalized_expr: str = expr
for op in ["<=", ">=", "<", ">", "*=", "^=", "$=", "~=", "="]:
# Replace "key op value" with "key op value" (removing extra spaces)
pattern: str = rf"([a-z_]+)\s*(!?)\s*({re.escape(op)})\s*"
replacement = r"\1\2\3"
normalized_expr = re.sub(pattern, replacement, normalized_expr)
token_spec: list[set] = [
(r"\|\|", "OR"), # must come before ATOM
(r"&", "AND"),
(r"\(", "LPAREN"),
(r"\)", "RPAREN"),
(r"[^\s&|()]+", "ATOM"), # don't allow whitespace in ATOM
(r"\s+", None), # skip whitespace
]
regex: str = "|".join(f"(?P<{name}>{pat})" for pat, name in token_spec if name)
master: re.Pattern[str] = re.compile(regex)
tokens: list[TOKEN] = []
for m in master.finditer(normalized_expr):
kind: str | None = m.lastgroup
if kind:
tokens.append((kind, m.group()))
return tokens
def _parse_or(self) -> AST_NODE:
"""
Parse OR-expression: and_expr ('||' and_expr)*
"""
left: AST_NODE = self._parse_and()
while self._accept("OR"):
right: AST_NODE = self._parse_and()
left = ("OR", left, right)
return left
def _parse_and(self) -> AST_NODE:
"""
Parse AND-expression: atom ('&' atom)*
"""
left: AST_NODE = self._parse_atom()
while self._accept("AND"):
right: AST_NODE = self._parse_atom()
left = ("AND", left, right)
return left
def _parse_atom(self) -> AST_NODE:
"""
Parse atomic expression: '(' expr ')' | ATOM
"""
if self._accept("LPAREN"):
node: AST_NODE = self._parse_or()
self._expect("RPAREN")
return node
tok: TOKEN = self._expect("ATOM")
return ("ATOM", tok[1].strip())
def _accept(self, kind: str) -> bool:
"""
Consume token if it matches expected kind.
"""
if self.pos < len(self.tokens) and kind == self.tokens[self.pos][0]:
self.pos += 1
return True
return False
def _expect(self, kind: str) -> TOKEN:
"""
Consume and return token if it matches expected kind,
otherwise raise SyntaxError.
"""
if self.pos < len(self.tokens) and kind == self.tokens[self.pos][0]:
tok = self.tokens[self.pos]
self.pos += 1
return tok
raise SyntaxError("Expected " + kind)
def _eval(self, node: AST_NODE, dct: dict) -> bool:
"""
Recursively evaluate AST node against dict.
"""
node_type: str = node[0]
if "ATOM" == node_type:
return self._match_one(node[1], dct)
if "AND" == node_type:
return self._eval(node[1], dct) and self._eval(node[2], dct)
if "OR" == node_type:
return self._eval(node[1], dct) or self._eval(node[2], dct)
raise ValueError("Invalid AST node " + node_type)
def _export(self, node: AST_NODE) -> list[list[str]]:
"""
Recursively flatten AST into AND-only filter groups.
"""
node_type = node[0]
if "ATOM" == node_type:
return [[node[1]]]
if "AND" == node_type:
left: list[list[str]] = self._export(node[1])
right: list[list[str]] = self._export(node[2])
return [_l + _r for _l in left for _r in right]
if "OR" == node_type:
left = self._export(node[1])
right = self._export(node[2])
return left + right
raise ValueError("Invalid AST node " + node_type)
def _match_one(self, filter_part: str, dct: dict) -> bool:
operator_rex: re.Pattern[str] = re.compile(
r"""(?x)
(?P<key>[a-z_]+)
\s*(?P<negation>!\s*)?(?P<op>{})(?P<none_inclusive>\s*\?)?\s*
(?:
(?P<quote>["\'])(?P<quoted_strval>.+?)(?P=quote)|
(?P<strval>.+?)
)
""".format("|".join(map(re.escape, self.COMPARISON_OPERATORS.keys())))
)
if m := operator_rex.fullmatch(filter_part.strip()):
from yt_dlp.utils import parse_duration, parse_filesize
g: dict[str, str | Any] = m.groupdict()
unnegated_op: Any = self.COMPARISON_OPERATORS[g["op"]]
op = (lambda a, v: not unnegated_op(a, v)) if g["negation"] else unnegated_op
comparison_value: str | Any = g["quoted_strval"] or g["strval"]
if g["quote"]:
comparison_value = comparison_value.replace(rf"\{g['quote']}", g["quote"])
actual_value: Any | None = dct.get(g["key"])
if None is actual_value:
return False
# numeric coercion using yt-dlp utils
numeric_comparison = None
try:
numeric_comparison = int(comparison_value)
except ValueError:
numeric_comparison: int | None | float = parse_filesize(comparison_value) or parse_duration(
comparison_value
)
if numeric_comparison is not None and g["op"] in self.STRING_OPERATORS:
msg = f"Operator {g['op']} only supports string values!"
raise ValueError(msg)
# Also try to convert actual_value to numeric if we have a numeric comparison
final_actual_value = actual_value
if numeric_comparison is not None and isinstance(actual_value, str):
try:
final_actual_value = int(actual_value)
except ValueError:
final_actual_value = parse_filesize(actual_value) or parse_duration(actual_value) or actual_value
return op(final_actual_value, numeric_comparison if None is not numeric_comparison else comparison_value)
# unary operators
operator_rex = re.compile(r"(?P<op>{})\s*(?P<key>[a-z_]+)".format("|".join(self.UNARY_OPERATORS)))
if m := operator_rex.fullmatch(filter_part.strip()):
op: Any = self.UNARY_OPERATORS[m.group("op")]
actual_value = dct.get(m.group("key"))
return op(actual_value)
msg: str = f"Invalid filter part {filter_part!r}"
raise ValueError(msg)

View file

@ -10,6 +10,7 @@ from app.library.conditions import Condition, Conditions
from app.library.config import Config from app.library.config import Config
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.mini_filter import match_str
from app.library.router import route from app.library.router import route
from app.library.Utils import extract_info, init_class, validate_uuid from app.library.Utils import extract_info, init_class, validate_uuid
from app.library.YTDLPOpts import YTDLPOpts from app.library.YTDLPOpts import YTDLPOpts
@ -181,8 +182,6 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
) )
try: try:
from yt_dlp.utils import match_str
status = match_str(cond, data) status = match_str(cond, data)
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)

View file

@ -0,0 +1,481 @@
import unittest
from typing import Any
from app.library.mini_filter import MiniFilter
class TestMiniFilter(unittest.TestCase):
@staticmethod
def _normalize(filters) -> set[str]:
"""
Normalize AND order so 'a&b' == 'b&a'
Args:
filters (list[str]): List of filter strings
Returns:
set[str]: Set of normalized filter strings
"""
return {"&".join(sorted(p.split("&"))) for p in filters}
def _test(
self, expr: str, test_data: dict, *, expected_result: bool, test_name: str = "", skip_ytdlp: bool = False
):
"""
Test both our implementation and yt-dlp's implementation to ensure they give the same result.
This method:
1. Tests our implementation directly on the full expression (supports OR, grouping)
2. Exports our expression to yt-dlp compatible format (flattens OR/grouping to multiple AND-only filters)
3. Tests yt-dlp on each exported filter (OR logic: any filter match = True)
4. Verifies both implementations agree
Args:
expr: Filter expression string (may contain OR, parentheses)
test_data: Dictionary to test against
expected_result: Expected boolean result (keyword-only)
test_name: Optional test name for better error messages
skip_ytdlp: Skip yt-dlp comparison due to known bugs (keyword-only)
"""
from yt_dlp.utils import match_str
# Step 1: Test our implementation directly
parser = MiniFilter(expr)
our_result = parser.evaluate(test_data)
# Step 2: Export to yt-dlp compatible format
# Our export() method flattens OR/grouping into multiple AND-only strings
exported_filters: list[str] = parser.export()
# Step 3: Test yt-dlp implementation (unless skipped)
if not skip_ytdlp:
# Since yt-dlp doesn't support OR, we test each exported filter separately
# The overall result is True if ANY exported filter matches (OR logic)
ytdlp_result = any(match_str(filter_part, test_data) for filter_part in exported_filters)
# Step 4b: Verify yt-dlp gives the expected result
assert ytdlp_result == expected_result, (
f"{test_name}: yt-dlp impl failed - expr: '{expr}' -> {exported_filters}, expected: {expected_result}, got: {ytdlp_result}"
)
# Step 5: Verify both implementations agree with each other
assert our_result == ytdlp_result, (
f"{test_name}: Implementations disagree - expr: '{expr}', our: {our_result}, yt-dlp: {ytdlp_result}, "
f"exported: {exported_filters}"
)
# Step 4a: Verify our implementation gives the expected result
assert our_result == expected_result, (
f"{test_name}: Our impl failed - expr: '{expr}', expected: {expected_result}, got: {our_result}"
)
return our_result
def test_simple_and(self):
expr = "filesize>1MB & duration<10m"
parser = MiniFilter(expr)
assert TestMiniFilter._normalize(parser.export()) == TestMiniFilter._normalize(["filesize>1MB&duration<10m"]), (
"Failed export check"
)
self._test(expr, {"filesize": 2000000, "duration": 200}, expected_result=True, test_name="simple_and_positive")
self._test(expr, {"filesize": 500000, "duration": 200}, expected_result=False, test_name="simple_and_negative")
def test_or(self):
expr = "uploader='BBC' || uploader='NHK'"
parser = MiniFilter(expr)
assert TestMiniFilter._normalize(parser.export()) == TestMiniFilter._normalize(
["uploader='BBC'", "uploader='NHK'"]
), "Failed export check"
self._test(expr, {"uploader": "BBC"}, expected_result=True, test_name="or_bbc")
self._test(expr, {"uploader": "NHK"}, expected_result=True, test_name="or_nhk")
self._test(expr, {"uploader": "CNN"}, expected_result=False, test_name="or_cnn")
def test_grouping(self):
expr = "(filesize>1MB & duration<10m) || uploader='BBC'"
parser = MiniFilter(expr)
assert TestMiniFilter._normalize(parser.export()) == TestMiniFilter._normalize(
["filesize>1MB&duration<10m", "uploader='BBC'"]
), "Failed export check"
self._test(
expr, {"filesize": 2000000, "duration": 200}, expected_result=True, test_name="grouping_filesize_match"
)
self._test(expr, {"uploader": "BBC"}, expected_result=True, test_name="grouping_uploader_match")
self._test(
expr,
{"filesize": 500000, "duration": 200, "uploader": "CNN"},
expected_result=False,
test_name="grouping_no_match",
)
def test_unary_presence(self):
# Test duration presence
self._test("duration", {"duration": 100}, expected_result=True, test_name="unary_duration_present")
self._test("duration", {}, expected_result=False, test_name="unary_duration_absent")
# Test duration absence
self._test("!duration", {}, expected_result=True, test_name="unary_not_duration_absent")
self._test("!duration", {"duration": 100}, expected_result=False, test_name="unary_not_duration_present")
def test_duration_units(self):
# Test with numeric duration values to avoid yt-dlp's inconsistent unit parsing
# Using 120 seconds = 2 minutes
self._test("duration<120", {"duration": 30}, expected_result=True, test_name="duration_120_positive")
self._test("duration<120", {"duration": 200}, expected_result=False, test_name="duration_120_negative")
# Test 90 seconds
self._test("duration<90", {"duration": 30}, expected_result=True, test_name="duration_90_positive")
self._test("duration<90", {"duration": 120}, expected_result=False, test_name="duration_90_negative")
# Test 3600 seconds = 1 hour
self._test("duration<3600", {"duration": 3599}, expected_result=True, test_name="duration_3600_positive")
self._test("duration<3600", {"duration": 3700}, expected_result=False, test_name="duration_3600_negative")
def test_duration_units_with_suffixes(self):
"""Test duration comparisons with time unit suffixes (m, h). Skip yt-dlp due to known parsing bugs."""
# Test 2 minutes (120 seconds)
self._test(
"duration<2m", {"duration": 90}, expected_result=True, test_name="duration_2m_positive", skip_ytdlp=True
)
self._test(
"duration<2m", {"duration": 150}, expected_result=False, test_name="duration_2m_negative", skip_ytdlp=True
)
# Test 5 minutes (300 seconds)
self._test(
"duration<5m", {"duration": 240}, expected_result=True, test_name="duration_5m_positive", skip_ytdlp=True
)
self._test(
"duration<5m", {"duration": 360}, expected_result=False, test_name="duration_5m_negative", skip_ytdlp=True
)
# Test 10 minutes (600 seconds)
self._test(
"duration<10m", {"duration": 480}, expected_result=True, test_name="duration_10m_positive", skip_ytdlp=True
)
self._test(
"duration<10m", {"duration": 720}, expected_result=False, test_name="duration_10m_negative", skip_ytdlp=True
)
# Test 1 hour (3600 seconds)
self._test(
"duration<1h", {"duration": 3000}, expected_result=True, test_name="duration_1h_positive", skip_ytdlp=True
)
self._test(
"duration<1h", {"duration": 4000}, expected_result=False, test_name="duration_1h_negative", skip_ytdlp=True
)
# Test >= operator with minutes
self._test(
"duration>=5m", {"duration": 300}, expected_result=True, test_name="duration_gte_5m_equal", skip_ytdlp=True
)
self._test(
"duration>=5m",
{"duration": 400},
expected_result=True,
test_name="duration_gte_5m_greater",
skip_ytdlp=True,
)
self._test(
"duration>=5m", {"duration": 200}, expected_result=False, test_name="duration_gte_5m_less", skip_ytdlp=True
)
def test_filesize_units(self):
# Test 1MB
self._test("filesize>1MB", {"filesize": 2000000}, expected_result=True, test_name="filesize_1mb_positive")
self._test("filesize>1MB", {"filesize": 500000}, expected_result=False, test_name="filesize_1mb_negative")
# Test 1GiB
self._test("filesize>=1GiB", {"filesize": 2**30}, expected_result=True, test_name="filesize_1gib_positive")
self._test("filesize>=1GiB", {"filesize": 1000000}, expected_result=False, test_name="filesize_1gib_negative")
def test_complex_duration_units_with_or_and_grouping(self):
"""Test complex expressions with duration units, OR operations, and grouping. Skip yt-dlp due to known parsing bugs."""
# Test grouping with duration units
expr = "(filesize>1MB & duration<10m) || uploader='BBC'"
self._test(
expr,
{"filesize": 2000000, "duration": 300},
expected_result=True,
test_name="complex_duration_filesize_match",
skip_ytdlp=True,
)
self._test(
expr,
{"uploader": "BBC"},
expected_result=True,
test_name="complex_duration_uploader_match",
skip_ytdlp=True,
)
self._test(
expr,
{"filesize": 500000, "duration": 300, "uploader": "CNN"},
expected_result=False,
test_name="complex_duration_no_match",
skip_ytdlp=True,
)
# Test OR with different duration units
expr = "duration<2m || duration>1h"
self._test(expr, {"duration": 60}, expected_result=True, test_name="complex_or_duration_short", skip_ytdlp=True)
self._test(
expr, {"duration": 4000}, expected_result=True, test_name="complex_or_duration_long", skip_ytdlp=True
)
self._test(
expr, {"duration": 1800}, expected_result=False, test_name="complex_or_duration_middle", skip_ytdlp=True
)
# Test complex expression with multiple duration conditions
expr = "(duration>30s & duration<5m) || (duration>1h & uploader*='BBC')"
self._test(
expr,
{"duration": 120},
expected_result=True,
test_name="complex_multi_duration_short_range",
skip_ytdlp=True,
)
self._test(
expr,
{"duration": 4000, "uploader": "BBC News"},
expected_result=True,
test_name="complex_multi_duration_long_bbc",
skip_ytdlp=True,
)
self._test(
expr,
{"duration": 4000, "uploader": "CNN"},
expected_result=False,
test_name="complex_multi_duration_long_cnn",
skip_ytdlp=True,
)
self._test(
expr,
{"duration": 1800},
expected_result=False,
test_name="complex_multi_duration_middle_range",
skip_ytdlp=True,
)
def test_string_operators(self):
d: dict[str, str] = {"uploader": "BBC News Channel"}
# Test all string operators with both implementations
self._test("uploader*='News'", d, expected_result=True, test_name="string_contains")
self._test("uploader^='BBC'", d, expected_result=True, test_name="string_startswith")
self._test("uploader$='Channel'", d, expected_result=True, test_name="string_endswith")
self._test("uploader~='News\\s+Channel'", d, expected_result=True, test_name="string_regex")
# Test negative cases
self._test("uploader*='CNN'", d, expected_result=False, test_name="string_contains_negative")
self._test("uploader^='CNN'", d, expected_result=False, test_name="string_startswith_negative")
self._test("uploader$='BBC'", d, expected_result=False, test_name="string_endswith_negative")
def test_spaces_around_operators(self):
"""Test that spaces around operators are handled correctly."""
d: dict[str, str] = {"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w", "uploader": "BBC"}
# Test with spaces around equals
self._test("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'", d, expected_result=True, test_name="spaced_equals_match")
self._test("channel_id = 'different-id'", d, expected_result=False, test_name="spaced_equals_non_match")
# Test with spaces in complex expressions
self._test(
"channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w' & uploader = 'BBC'",
d,
expected_result=True,
test_name="complex_spaced_expression",
)
self._test(
"channel_id = 'different-id' & uploader = 'BBC'",
d,
expected_result=False,
test_name="complex_spaced_non_match",
)
# Test various operators with spaces
d_numeric: dict[str, int] = {"filesize": 2000000, "duration": 200}
self._test("filesize > 1000000", d_numeric, expected_result=True, test_name="spaced_greater_than")
self._test("filesize >= 2000000", d_numeric, expected_result=True, test_name="spaced_greater_equal")
self._test("duration < 300", d_numeric, expected_result=True, test_name="spaced_less_than")
self._test("duration <= 200", d_numeric, expected_result=True, test_name="spaced_less_equal")
def test_original_bug_reproduction(self):
"""Test the exact case from the original bug report."""
# Exact data from the original bug report
test_data: dict[str, Any] = {
"age_limit": 0,
"comment_count": 6,
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
"uploader_url": "https://www.youtube.com/@PlayFramePlus",
}
# This filter should return FALSE because:
# 1. channel_id doesn't match (UC-7oMv6E4Uz2tF51w5Sj49w vs UCfmrcEdes7yDtEISGPM1T-A)
# 2. availability key doesn't exist in the data
filter_expr = "channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only"
self._test(filter_expr, test_data, expected_result=False, test_name="original_bug_full")
# Individual parts should also be false
self._test(
"channel_id = 'UCfmrcEdes7yDtEISGPM1T-A'",
test_data,
expected_result=False,
test_name="original_bug_wrong_channel",
)
self._test(
"availability = subscriber_only", test_data, expected_result=False, test_name="original_bug_missing_key"
)
# But the correct channel_id should match
self._test(
"channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'",
test_data,
expected_result=True,
test_name="original_bug_correct_channel",
)
def test_or_operator_precedence(self):
"""Test operator precedence and grouping with OR statements."""
# Test data for the examples
test_data: dict[str, int] = {
"age_limit": 0,
"fps": 120,
"like_count": 81,
}
# Case 1: (age_limit=0 & fps=120) || like_count=81
# This should evaluate as: (True & True) || True = True || True = True
expr1 = "(age_limit=0 & fps=120) || like_count=81"
self._test(expr1, test_data, expected_result=True, test_name="or_precedence_case1")
# Case 2: age_limit=0 & fps=120 || like_count=81
# This evaluates left-to-right as: (age_limit=0 & fps=120) || like_count=81
# = (True & True) || True = True || True = True
expr2 = "age_limit=0 & fps=120 || like_count=81"
self._test(expr2, test_data, expected_result=True, test_name="or_precedence_case2")
# Test with data that shows left-to-right evaluation
test_data_partial: dict[str, int] = {
"age_limit": 0,
"fps": 60, # Changed from 120 to make first AND false
"like_count": 81,
}
# Case 1 with partial data: (age_limit=0 & fps=120) || like_count=81
# This should be: (True & False) || True = False || True = True
self._test(expr1, test_data_partial, expected_result=True, test_name="or_precedence_case1_partial")
# Case 2 with partial data: age_limit=0 & fps=120 || like_count=81
# This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (True & False) || True = False || True = True
self._test(expr2, test_data_partial, expected_result=True, test_name="or_precedence_case2_partial")
# Test case where the difference becomes clear
test_data_edge: dict[str, int] = {
"age_limit": 1, # Changed from 0 to make age_limit=0 false
"fps": 60, # Changed from 120 to make fps=120 false
"like_count": 81,
}
# Case 1 with edge data: (age_limit=0 & fps=120) || like_count=81
# This should be: (False & False) || True = False || True = True
self._test(expr1, test_data_edge, expected_result=True, test_name="or_precedence_case1_edge")
# Case 2 with edge data: age_limit=0 & fps=120 || like_count=81
# This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (False & False) || True = False || True = True
self._test(expr2, test_data_edge, expected_result=True, test_name="or_precedence_case2_edge")
def test_complex_or_precedence_scenarios(self):
"""Test more complex OR precedence scenarios."""
# Test case where only the OR part is true
test_data_or_only: dict[str, int] = {
"age_limit": 1, # False
"fps": 60, # False (not 120)
"like_count": 81, # True
}
# Expression: a & b || c
# Evaluates left-to-right as: (a & b) || c = (False & False) || True = False || True = True
expr = "age_limit=0 & fps=120 || like_count=81"
self._test(expr, test_data_or_only, expected_result=True, test_name="complex_or_only_or_true")
# Test case where only the AND part is true
test_data_and_only: dict[str, int] = {
"age_limit": 0, # True
"fps": 120, # True
"like_count": 50, # False (not 81)
}
# Expression: a & b || c
# Evaluates as: (a & b) || c = (True & True) || False = True || False = True
self._test(expr, test_data_and_only, expected_result=True, test_name="complex_or_only_and_true")
# Test case where everything is false except the OR part
test_data_only_or: dict[str, int] = {
"age_limit": 1, # False
"fps": 60, # False
"like_count": 50, # False
"view_count": 1000, # True
}
# Test chained OR with AND: a & b || c || d
# Evaluates left-to-right as: ((a & b) || c) || d
expr_chain = "age_limit=0 & fps=120 || like_count=81 || view_count=1000"
self._test(expr_chain, test_data_only_or, expected_result=True, test_name="complex_or_chained")
def test_comprehensive_comparison(self):
"""
Comprehensive test to ensure our implementation and yt-dlp's always agree
on a variety of test cases.
"""
test_cases = [
# Simple cases - use numeric values to avoid yt-dlp unit parsing inconsistencies
("filesize>1000000", {"filesize": 2000000}, True), # 1MB = 1000000 bytes
("filesize>1000000", {"filesize": 500000}, False),
("duration<600", {"duration": 200}, True), # 10min = 600 seconds
("duration<600", {"duration": 800}, False),
# String operations
("uploader*='BBC'", {"uploader": "BBC News"}, True),
("uploader*='BBC'", {"uploader": "CNN News"}, False),
("title^='How'", {"title": "How to code"}, True),
("title^='How'", {"title": "Learn how"}, False),
# AND operations - use numeric values
("filesize>1000000 & duration<600", {"filesize": 2000000, "duration": 200}, True),
("filesize>1000000 & duration<600", {"filesize": 500000, "duration": 200}, False),
("filesize>1000000 & duration<600", {"filesize": 2000000, "duration": 800}, False),
# Unary operations
("duration", {"duration": 100}, True),
("duration", {}, False),
("!duration", {}, True),
("!duration", {"duration": 100}, False),
# Complex expressions with parentheses and OR - use numeric values
("(filesize>1000000 & duration<600) || uploader*='BBC'", {"filesize": 2000000, "duration": 200}, True),
("(filesize>1000000 & duration<600) || uploader*='BBC'", {"uploader": "BBC News"}, True),
("(filesize>1000000 & duration<600) || uploader*='BBC'", {"filesize": 500000, "uploader": "CNN"}, False),
# Edge cases with missing fields
("missing_field=test", {"other_field": "value"}, False),
("!missing_field", {"other_field": "value"}, True),
]
for expr, test_data, expected in test_cases:
test_name = f"comprehensive_{expr.replace(' ', '_').replace('>', 'gt').replace('<', 'lt').replace('=', 'eq').replace('&', 'and').replace('||', 'or').replace('(', '').replace(')', '').replace('*', 'contains').replace('^', 'starts').replace('!', 'not')}"
self._test(expr, test_data, expected_result=expected, test_name=test_name)
def test_export_and_yt_dlp_compat(self):
from yt_dlp.utils import match_str
d: dict[str, str] = {"filesize": 2000000, "duration": 200, "uploader": "BBC"}
# Use numeric values to avoid yt-dlp unit parsing inconsistencies
expr = "(filesize>1000000 & duration<600) || uploader='BBC'"
parser = MiniFilter(expr)
for part in parser.export():
assert match_str(part, d), f"Failed to match {part} with {d}"
if __name__ == "__main__":
unittest.main()

1019
app/tests/test_utils.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -79,9 +79,7 @@ indent-width = 4
target-version = "py311" target-version = "py311"
[project.optional-dependencies] [project.optional-dependencies]
installer = [ installer = ["pyinstaller"]
"pyinstaller",
]
[tool.ruff.lint] [tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
@ -200,3 +198,8 @@ docstring-code-line-length = "dynamic"
[tool.uv] [tool.uv]
link-mode = "copy" link-mode = "copy"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["app/tests"]
addopts = "-v --tb=short"

35
ui/.prettierrc Normal file
View file

@ -0,0 +1,35 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"tabWidth": 2,
"useTabs": false,
"printWidth": 120,
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"vueIndentScriptAndStyle": true,
"htmlWhitespaceSensitivity": "ignore",
"overrides": [
{
"files": "*.vue",
"options": {
"parser": "vue"
}
},
{
"files": ["*.ts", "*.tsx"],
"options": {
"parser": "typescript"
}
},
{
"files": "*.json",
"options": {
"parser": "json",
"singleQuote": false
}
}
]
}

View file

@ -99,7 +99,7 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are <NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
supported <NuxtLink target="_blank" supported <NuxtLink target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution. are ignored</NuxtLink>. Use with caution.
@ -454,7 +454,9 @@ const logic_test = computed(() => {
} }
try { try {
return match_str(form.filter, test_data.value.data.data, true) const st = match_str(form.filter, test_data.value.data.data)
console.log('Logic test:', st, form.filter, test_data.value.data.data)
return st
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
return false return false

View file

@ -270,7 +270,7 @@ const validateItem = (item: DLField, index: number): boolean => {
return false return false
} }
if (!/^--[a-zA-Z0-9\-]+$/.test(item.field)) { if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`) toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
return false return false
} }
@ -278,6 +278,7 @@ const validateItem = (item: DLField, index: number): boolean => {
return true return true
} }
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0))) const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions

View file

@ -149,6 +149,8 @@ const defaultTitle = computed(() => {
return 'Confirm' return 'Confirm'
case 'prompt': case 'prompt':
return 'Input required' return 'Input required'
default:
return 'Dialog'
} }
}) })
</script> </script>

View file

@ -1,9 +1,9 @@
<template> <template>
<vTooltip @show="loadContent" @hide="stopTimer"> <vTooltip @show="loadContent" @hide="stopTimer">
<slot /> <slot />
<template #popper class="p-0 m-0"> <template #popper>
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span> <span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
<template v-else> <div v-else>
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1"> <div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
<div class="is-block" style="word-break: all;" v-if="props.title"> <div class="is-block" style="word-break: all;" v-if="props.title">
<span style="font-size: 120%;">{{ props.title }}</span> <span style="font-size: 120%;">{{ props.title }}</span>
@ -14,7 +14,7 @@
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" /> :referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
</figure> </figure>
</div> </div>
</template> </div>
</template> </template>
</vTooltip> </vTooltip>
</template> </template>

View file

@ -504,7 +504,7 @@ const dialog_confirm = ref<{
options: [], options: [],
}) })
const showThumbnails = computed(() => (props.thumbnails || true) && !hideThumbnail.value) const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value)
const playVideo = (item: StoreItem) => { video_item.value = item } const playVideo = (item: StoreItem) => { video_item.value = item }
const closeVideo = () => { video_item.value = null } const closeVideo = () => { video_item.value = null }

View file

@ -39,13 +39,13 @@
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset" :disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''"> v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0"> <optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name"> <option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
{{ item.name }} {{ cPreset.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup label="Default presets"> <optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name"> <option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
{{ item.name }} {{ dPreset.name }}
</option> </option>
</optgroup> </optgroup>
</select> </select>
@ -127,7 +127,7 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are supported <NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are supported
<NuxtLink target="_blank" <NuxtLink target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution. are ignored</NuxtLink>. Use with caution.
@ -238,7 +238,6 @@ const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void (e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
(e: 'clear_form'): void (e: 'clear_form'): void
(e: 'remove_archive', url: string): void
}>() }>()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
@ -303,7 +302,7 @@ const addDownload = async () => {
continue continue
} }
const keyRegex = new RegExp(`(^|\s)${key}(\s|$)`); const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
if (form_cli && keyRegex.test(form_cli)) { if (form_cli && keyRegex.test(form_cli)) {
continue; continue;
} }
@ -514,5 +513,6 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
return ret return ret
} }
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0))) const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
</script> </script>

View file

@ -100,8 +100,8 @@
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress"> <select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item"> <option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod">
{{ item }} {{ rMethod }}
</option> </option>
</select> </select>
</div> </div>
@ -125,8 +125,8 @@
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress"> <select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item"> <option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType">
{{ ucFirst(item) }} {{ ucFirst(rType) }}
</option> </option>
</select> </select>
</div> </div>
@ -153,8 +153,8 @@
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-multiple is-fullwidth"> <div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple> <select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item"> <option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent">
{{ item }} {{ aEvent }}
</option> </option>
</select> </select>
</div> </div>
@ -183,13 +183,13 @@
<div class="select is-multiple is-fullwidth"> <div class="select is-multiple is-fullwidth">
<select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple> <select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple>
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0"> <optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
<option v-for="item in filter_presets(false)" :key="item.id" :value="item.name"> <option v-for="cPreset in filter_presets(false)" :key="cPreset.id" :value="cPreset.name">
{{ item.name }} {{ cPreset.name }}
</option> </option>
</optgroup> </optgroup>
<optgroup label="Default presets"> <optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.id" :value="item.name"> <option v-for="dPreset in filter_presets(true)" :key="dPreset.id" :value="dPreset.name">
{{ item.name }} {{ dPreset.name }}
</option> </option>
</optgroup> </optgroup>
</select> </select>

View file

@ -154,7 +154,7 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are <NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
supported <NuxtLink target="_blank" supported <NuxtLink target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution. are ignored</NuxtLink>. Use with caution.

View file

@ -144,6 +144,7 @@ import { useStorage } from '@vueuse/core'
import { POSITION } from 'vue-toastification' import { POSITION } from 'vue-toastification'
defineProps<{ isLoading: boolean }>() defineProps<{ isLoading: boolean }>()
defineEmits<{ (e: 'reload_bg'): void }>()
const bg_enable = useStorage<boolean>('random_bg', true) const bg_enable = useStorage<boolean>('random_bg', true)
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95) const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)

View file

@ -242,7 +242,7 @@
<span class="help is-bold"> <span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span> <span>
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are <NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
supported <NuxtLink target="_blank" supported <NuxtLink target="_blank"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution. are ignored</NuxtLink>. Use with caution.

View file

@ -5,7 +5,7 @@
<nav class="navbar is-mobile is-dark"> <nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5"> <div class="navbar-brand pl-5">
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.native="(e: MouseEvent) => changeRoute(e)" <NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'"> v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
<span class="is-text-overflow"> <span class="is-text-overflow">
<span class="icon"><i class="fas fa-home" /></span> <span class="icon"><i class="fas fa-home" /></span>
@ -25,30 +25,30 @@
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }"> <div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
<div class="navbar-start" v-if="!config.app.basic_mode"> <div class="navbar-start" v-if="!config.app.basic_mode">
<NuxtLink class="navbar-item" to="/browser" @click.native="(e: MouseEvent) => changeRoute(e)" <NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.browser_enabled"> v-if="config.app.browser_enabled">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span> <span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span> <span>Files</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/presets" @click.native="(e: MouseEvent) => changeRoute(e)"> <NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-sliders" /></span> <span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span> <span>Presets</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/tasks" @click.native="(e: MouseEvent) => changeRoute(e)"> <NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-tasks" /></span> <span class="icon"><i class="fa-solid fa-tasks" /></span>
<span>Tasks</span> <span>Tasks</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/notifications" @click.native="(e: MouseEvent) => changeRoute(e)"> <NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-paper-plane" /></span> <span class="icon"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span> <span>Notifications</span>
</span> </span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/conditions" @click.native="(e: MouseEvent) => changeRoute(e)"> <NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
<span class="icon"><i class="fa-solid fa-filter" /></span> <span class="icon"><i class="fa-solid fa-filter" /></span>
<span>Conditions</span> <span>Conditions</span>
</NuxtLink> </NuxtLink>
@ -62,13 +62,13 @@
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/logs" @click.native="(e: MouseEvent) => changeRoute(e)" <NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.file_logging"> v-if="config.app.file_logging">
<span class="icon"><i class="fa-solid fa-file-lines" /></span> <span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span> <span>Logs</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="navbar-item" to="/console" @click.native="(e: MouseEvent) => changeRoute(e)" <NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
v-if="config.app.console_enabled"> v-if="config.app.console_enabled">
<span class="icon"><i class="fa-solid fa-terminal" /></span> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span> <span>Console</span>

View file

@ -111,7 +111,7 @@
<NewDownload v-if="config.showForm || config.app.basic_mode" <NewDownload v-if="config.showForm || config.app.basic_mode"
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:item="item_form" @clear_form="item_form = {}" @remove_archive="" /> :item="item_form" @clear_form="item_form = {}" />
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)" <Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" :thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''" /> @clear_search="query = ''" />

View file

@ -78,7 +78,7 @@
<td class="is-text-overflow is-vcentered"> <td class="is-text-overflow is-vcentered">
<div class="is-bold"> <div class="is-bold">
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @ {{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink> <NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
</div> </div>
<div class="is-unselectable"> <div class="is-unselectable">
<span class="icon-text"> <span class="icon-text">
@ -150,7 +150,7 @@
</p> </p>
<p v-if="item.request?.headers && item.request.headers.length > 0"> <p v-if="item.request?.headers && item.request.headers.length > 0">
<span class="icon"><i class="fa-solid fa-heading" /></span> <span class="icon"><i class="fa-solid fa-heading" /></span>
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span> <span>{{ item.request.headers.map(h => h.key).join(', ') }}</span>
</p> </p>
</div> </div>
</div> </div>

View file

@ -780,7 +780,7 @@ const exportItem = async (item: task_item) => {
const get_tags = (name: string): Array<string> => { const get_tags = (name: string): Array<string> => {
const regex = /\[(.*?)\]/g; const regex = /\[(.*?)\]/g;
const matches = name.match(regex); const matches = name.match(regex);
return !matches ? [] : matches.map(tag => tag.replace(/[\[\]]/g, '').trim()); return !matches ? [] : matches.map(tag => tag.replace(/[[\]]/g, '').trim());
} }
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim(); const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();

238
ui/app/utils/ytdlp.test.ts Normal file
View file

@ -0,0 +1,238 @@
import { describe, it, expect } from 'vitest'
import { MatchFilterParser } from './ytdlp'
function normalize(filters: string[]): Set<string> {
return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));
}
describe("MatchFilterParser", () => {
it("handles simple AND", () => {
const parser = new MatchFilterParser("filesize>1000000 & duration<600");
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
expect(parser.evaluate({ filesize: 500_000, duration: 200 })).toBe(false);
expect(parser.evaluate({ filesize: 2_000_000, duration: 800 })).toBe(false);
});
it("handles OR", () => {
const parser = new MatchFilterParser("uploader='BBC' || uploader='NHK'");
expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
expect(parser.evaluate({ uploader: "NHK" })).toBe(true);
expect(parser.evaluate({ uploader: "CNN" })).toBe(false);
});
it("handles grouping", () => {
const parser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
expect(parser.evaluate({ filesize: 500_000, duration: 200, uploader: "CNN" })).toBe(false);
});
it("handles unary presence", () => {
expect(new MatchFilterParser("duration").evaluate({ duration: 100 })).toBe(true);
expect(new MatchFilterParser("duration").evaluate({})).toBe(false);
expect(new MatchFilterParser("!duration").evaluate({})).toBe(true);
expect(new MatchFilterParser("!duration").evaluate({ duration: 100 })).toBe(false);
});
it("parses duration with numeric values", () => {
// Use numeric seconds instead of unit formats for consistency
// 120 seconds = 2 minutes
expect(new MatchFilterParser("duration<120").evaluate({ duration: 30 })).toBe(true);
expect(new MatchFilterParser("duration<120").evaluate({ duration: 200 })).toBe(false);
// 90 seconds
expect(new MatchFilterParser("duration<90").evaluate({ duration: 30 })).toBe(true);
expect(new MatchFilterParser("duration<90").evaluate({ duration: 120 })).toBe(false);
// 3600 seconds = 1 hour
expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3599 })).toBe(true);
expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3700 })).toBe(false);
});
it("parses filesize with numeric values", () => {
// Use numeric bytes instead of unit formats for consistency
// 1MB = 1,000,000 bytes
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 2_000_000 })).toBe(true);
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 500_000 })).toBe(false);
// 1GiB = 1,073,741,824 bytes
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 2 ** 30 })).toBe(true);
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 1000000 })).toBe(false);
});
it("handles string operators", () => {
const d = { uploader: "BBC News Channel" };
// Test all string operators with positive cases
expect(new MatchFilterParser("uploader*='News'").evaluate(d)).toBe(true); // contains
expect(new MatchFilterParser("uploader^='BBC'").evaluate(d)).toBe(true); // starts with
expect(new MatchFilterParser("uploader$='Channel'").evaluate(d)).toBe(true); // ends with
expect(new MatchFilterParser("uploader~='News\\s+Channel'").evaluate(d)).toBe(true); // regex
// Test negative cases
expect(new MatchFilterParser("uploader*='CNN'").evaluate(d)).toBe(false); // contains
expect(new MatchFilterParser("uploader^='CNN'").evaluate(d)).toBe(false); // starts with
expect(new MatchFilterParser("uploader$='BBC'").evaluate(d)).toBe(false); // ends with
expect(new MatchFilterParser("uploader~='News\\s+Network'").evaluate(d)).toBe(false); // regex
});
it("handles spaces around operators", () => {
const d = {
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
"uploader": "BBC"
};
// Test with spaces around equals
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(d)).toBe(true);
expect(new MatchFilterParser("channel_id = 'different-id'").evaluate(d)).toBe(false);
// Test with spaces in complex expressions
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w' & uploader = 'BBC'").evaluate(d)).toBe(true);
expect(new MatchFilterParser("channel_id = 'different-id' & uploader = 'BBC'").evaluate(d)).toBe(false);
// Test the specific failing case from the bug report
expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only").evaluate(d)).toBe(false);
});
it("reproduces original bug report", () => {
// Exact data from the original bug report
const testData = {
"age_limit": 0,
"comment_count": 6,
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
"uploader_url": "https://www.youtube.com/@PlayFramePlus"
};
// This filter should return FALSE because:
// 1. channel_id doesn't match (UC-7oMv6E4Uz2tF51w5Sj49w vs UCfmrcEdes7yDtEISGPM1T-A)
// 2. availability key doesn't exist in the data
const filter = "channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only";
expect(new MatchFilterParser(filter).evaluate(testData)).toBe(false);
// Individual parts should also be false
expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A'").evaluate(testData)).toBe(false);
expect(new MatchFilterParser("availability = subscriber_only").evaluate(testData)).toBe(false);
// But the correct channel_id should match
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(testData)).toBe(true);
});
it("tests OR operator precedence", () => {
// Test data for the examples
const testData = {
"age_limit": 0,
"fps": 120,
"like_count": 81,
};
// Case 1: (age_limit=0 & fps=120) || like_count=81
// This should evaluate as: (True & True) || True = True || True = True
const expr1 = "(age_limit=0 & fps=120) || like_count=81";
expect(new MatchFilterParser(expr1).evaluate(testData)).toBe(true);
// Case 2: age_limit=0 & fps=120 || like_count=81
// This evaluates left-to-right as: (age_limit=0 & fps=120) || like_count=81
// = (True & True) || True = True || True = True
const expr2 = "age_limit=0 & fps=120 || like_count=81";
expect(new MatchFilterParser(expr2).evaluate(testData)).toBe(true);
// Test with data that shows left-to-right evaluation
const testDataPartial = {
"age_limit": 0,
"fps": 60, // Changed from 120 to make first AND false
"like_count": 81,
};
// Case 1 with partial data: (age_limit=0 & fps=120) || like_count=81
// This should be: (True & False) || True = False || True = True
expect(new MatchFilterParser(expr1).evaluate(testDataPartial)).toBe(true);
// Case 2 with partial data: age_limit=0 & fps=120 || like_count=81
// This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (True & False) || True = False || True = True
expect(new MatchFilterParser(expr2).evaluate(testDataPartial)).toBe(true);
// Test case where the difference becomes clear
const testDataEdge = {
"age_limit": 1, // Changed from 0 to make age_limit=0 false
"fps": 60, // Changed from 120 to make fps=120 false
"like_count": 81,
};
// Case 1 with edge data: (age_limit=0 & fps=120) || like_count=81
// This should be: (False & False) || True = False || True = True
expect(new MatchFilterParser(expr1).evaluate(testDataEdge)).toBe(true);
// Case 2 with edge data: age_limit=0 & fps=120 || like_count=81
// This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (False & False) || True = False || True = True
expect(new MatchFilterParser(expr2).evaluate(testDataEdge)).toBe(true);
});
it("tests complex OR precedence scenarios", () => {
// Test case where only the OR part is true
const testDataOrOnly = {
"age_limit": 1, // False
"fps": 60, // False (not 120)
"like_count": 81, // True
};
// Expression: a & b || c
// Evaluates left-to-right as: (a & b) || c = (False & False) || True = False || True = True
const expr = "age_limit=0 & fps=120 || like_count=81";
expect(new MatchFilterParser(expr).evaluate(testDataOrOnly)).toBe(true);
// Test case where only the AND part is true
const testDataAndOnly = {
"age_limit": 0, // True
"fps": 120, // True
"like_count": 50, // False (not 81)
};
// Expression: a & b || c
// Evaluates as: (a & b) || c = (True & True) || False = True || False = True
expect(new MatchFilterParser(expr).evaluate(testDataAndOnly)).toBe(true);
// Test case where everything is false except the OR part
const testDataOnlyOr = {
"age_limit": 1, // False
"fps": 60, // False
"like_count": 50, // False
"view_count": 1000, // True
};
// Test chained OR with AND: a & b || c || d
// Evaluates left-to-right as: ((a & b) || c) || d
const exprChain = "age_limit=0 & fps=120 || like_count=81 || view_count=1000";
expect(new MatchFilterParser(exprChain).evaluate(testDataOnlyOr)).toBe(true);
});
it("exports filters correctly", () => {
// Test simple AND expression
const simpleParser = new MatchFilterParser("filesize>1000000 & duration<600");
const simpleExported = simpleParser.export();
expect(simpleExported).toEqual(["filesize>1000000&duration<600"]);
// Test OR expression (should split into multiple filters)
const orParser = new MatchFilterParser("filesize>1000000 || uploader='BBC'");
const orExported = orParser.export();
expect(normalize(orExported)).toEqual(normalize(["filesize>1000000", "uploader='BBC'"]));
// Test complex expression with grouping
const complexParser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
const complexExported = complexParser.export();
expect(normalize(complexExported)).toEqual(normalize(["filesize>1000000&duration<600", "uploader='BBC'"]));
// Verify exported filters work individually
const testData = { filesize: 2000000, duration: 300 };
for (const filter of complexExported) {
const filterParser = new MatchFilterParser(filter);
// At least one of the exported filters should match
if (filterParser.evaluate(testData)) {
expect(true).toBe(true); // Success if any filter matches
break;
}
}
});
});

View file

@ -1,176 +1,304 @@
const NUMBER_RE = /\d+(?:\.\d+)?/; const NUMBER_RE = /\d+(?:\.\d+)?/;
function match_str(filterStr: string, dct: Record<string, any>, incomplete: boolean | Set<string> = false): boolean {
if (!filterStr) {
return true;
}
if (/\s*&\s*$/.test(filterStr)) {
return false;
}
try {
const filterParts = filterStr
.split(/(?:(^|[^\\]))&/g)
.reduce((parts, part, index, array) => {
if (index % 2 === 0) {
parts.push((part + (array[index + 1] || '')).replace(/\\&/g, '&'));
}
return parts;
}, [] as string[]);
if (filterParts.some(fp => !fp.trim())) {
return false;
}
return filterParts.every(filterPart => _match_one(filterPart, dct, incomplete));
} catch (e) {
return false;
}
}
function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null { function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null {
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/; const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
const unitsRe = Object.keys(unitTable).map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); const unitsRe = Object.keys(unitTable)
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? '' : 'i'); .map(u => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? "" : "i");
const m = s.match(re); const m = s.match(re);
if (!m?.groups?.num || !m.groups.unit) { if (!m?.groups?.num || !m.groups.unit) {
return null; return null;
} }
const numStr = m.groups.num.replace(',', '.'); const numStr = m.groups.num.replace(",", ".");
const unit = m.groups.unit; const unit = m.groups.unit;
const mult = unitTable[unit]; const mult = unitTable[unit];
if (undefined === mult) { if (undefined === mult) return null;
return null;
}
return Math.round(parseFloat(numStr) * mult); return Math.round(parseFloat(numStr) * mult);
} }
function parse_filesize(s: string | null): number | null { function parse_filesize(s: string | null): number | null {
if (!s) return null; if (!s) return null;
const UNIT_TABLE: Record<string, number> = { const UNIT_TABLE: Record<string, number> = {
B: 1, bytes: 1, b: 1, B: 1,
b: 1,
bytes: 1,
KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024, KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024,
MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2, MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2,
GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3, GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3,
TiB: 1024 ** 4, TB: 1000 ** 4, terabytes: 1000 ** 4, tebibytes: 1024 ** 4, 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); return lookup_unit_table(UNIT_TABLE, s);
} }
function parse_duration(s: string): number | null { function parse_duration(s: string): number | null {
if (!s.trim()) { if (!s?.trim()) return null;
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;
}
} }
const durationRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/; // Support hh:mm:ss[.ms]
const m = s.match(durationRe); const hmsRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
if (!m?.groups) { const m = s.match(hmsRe);
return null; 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(":", "."))
);
} }
const { days, hours, mins, secs, ms } = m.groups; return null;
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<string, any>, incomplete: boolean | Set<string>): boolean {
// ---------- Match One ----------
function _match_one(filterPart: string, dct: Record<string, any>): boolean {
const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = { const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = {
'*=': (a, b) => a.includes(b), "*=": (a, b) => a.includes(b),
'^=': (a, b) => a.startsWith(b), "^=": (a, b) => a.startsWith(b),
'$=': (a, b) => a.endsWith(b), "$=": (a, b) => a.endsWith(b),
'~=': (a, b) => new RegExp(b).test(a), "~=": (a, b) => new RegExp(b).test(a),
}; };
const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = { const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
'*=': STRING_OPERATORS['*=']!, "*=": STRING_OPERATORS["*="]!,
'^=': STRING_OPERATORS['^=']!, "^=": STRING_OPERATORS["^="]!,
'$=': 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, ">=": (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 isIncomplete = typeof incomplete === 'boolean' ? (_: string) => incomplete : (k: string) => incomplete.has(k); const cmpOps = Object.keys(COMPARISON_OPERATORS)
.map(op => op.replace(/([.*+?^${}()|\[\]\\])/g, "\\$1"))
.join("|");
const cmpOps = Object.keys(COMPARISON_OPERATORS).map(op => op.replace(/([.*+?^${}()|\[\]\\])/g, '\\$1')).join('|'); const operatorRe = new RegExp(
const operatorRe = new RegExp(`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+))$`); `^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+?))?$`
);
const m = filterPart.trim().match(operatorRe); const m = filterPart.trim().match(operatorRe);
if (m?.groups) { if (m?.groups) {
const { key, negation, op, noneInclusive, quote, quoted, plain } = m.groups; const { key, negation, op, quote, quoted, plain } = m.groups;
const unnegatedOp = COMPARISON_OPERATORS[op!]; const unnegatedOp = COMPARISON_OPERATORS[op!];
if (!unnegatedOp) { if (!unnegatedOp) throw new Error(`Invalid operator '${op}'`);
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; const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
let processedValue = value;
let value = quoted ?? plain ?? ''; if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, "g"), quote);
if (quote) {
value = value.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
}
if (!(key! in dct)) {
return isIncomplete(key!) || Boolean(noneInclusive);
}
const actual = dct[key!]; const actual = dct[key!];
let numeric: number | null = null; if (actual === undefined || actual === null) {
return false;
}
if (typeof actual === 'number') { let numeric: number | null = null;
numeric = Number(value); // Try to convert comparison value to numeric first
if (Number.isNaN(numeric)) { numeric = Number(processedValue);
numeric = parse_filesize(value) ?? parse_filesize(`${value}B`) ?? parse_duration(value); 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) { if (numeric !== null && op! in STRING_OPERATORS) {
throw new Error(`Operator ${op} only supports string values!`); throw new Error(`Operator ${op} only supports string values!`);
} }
return opFn(finalActual, numeric !== null ? numeric : processedValue);
return opFn(actual, numeric !== null ? numeric : value);
} }
const UNARY_OPERATORS: Record<string, (v: any) => boolean> = { // unary operator
'': v => typeof v === 'boolean' ? v === true : v != null, const unaryRe = /^(?<op>!?)\s*(?<key>[a-z_]+)$/;
'!': v => typeof v === 'boolean' ? v === false : v == null,
};
const unaryRe = new RegExp(`^(?<op>!?)\\s*(?<key>[a-z_]+)$`);
const mu = filterPart.trim().match(unaryRe); const mu = filterPart.trim().match(unaryRe);
if (mu?.groups) { if (mu?.groups) {
const { op, key } = mu.groups; const { op, key } = mu.groups;
if (!(key! in dct)) {
return isIncomplete(key!);
}
const actual = dct[key!]; const actual = dct[key!];
const unaryOp = UNARY_OPERATORS[op!]; return UNARY_OPERATORS[op!]!(actual);
if (!unaryOp) {
throw new Error(`Invalid unary operator '${op}'`);
}
return unaryOp(actual);
} }
throw new Error(`Invalid filter part '${filterPart}'`); throw new Error(`Invalid filter part '${filterPart}'`);
} }
export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one } // ---------- 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 }

55
ui/eslint.config.js Normal file
View file

@ -0,0 +1,55 @@
// eslint.config.js
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
import vueParser from 'vue-eslint-parser'
import tsParser from '@typescript-eslint/parser'
export default withNuxt(
{
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
extraFileExtensions: ['.vue']
}
},
rules: {
'vue/valid-template-root': 'off',
'vue/no-multiple-template-root': 'off',
'vue/multi-word-component-names': 'off',
'vue/no-undef-components': 'off',
'vue/no-undef-properties': 'off',
'vue/script-setup-uses-vars': 'off',
'vue/html-quotes': ['warn', 'double'],
'vue/mustache-interpolation-spacing': ['warn', 'always'],
'vue/v-bind-style': ['warn', 'shorthand'],
'vue/v-on-style': ['warn', 'shorthand'],
'vue/attributes-order': 'off',
'vue/html-self-closing': 'off',
'vue/first-attribute-linebreak': 'off',
'vue/attribute-hyphenation': 'off',
'vue/v-on-event-hyphenation': 'off',
'vue/block-order':'off',
'vue/prop-name-casing': 'off',
'vue/no-v-html': 'off',
'no-empty': ['error', { allowEmptyCatch: true }],
'no-console': 'off',
'no-debugger': 'warn',
'no-alert': 'warn',
'no-undef': 'off',
'no-unused-vars': 'off',
'vue/no-unused-vars': 'off'
}
},
{
files: ['**/*.vue'],
rules: {
'vue/no-parsing-error': 'off'
}
})

View file

@ -60,6 +60,7 @@ export default defineNuxtConfig({
'@pinia/nuxt', '@pinia/nuxt',
'@vueuse/nuxt', '@vueuse/nuxt',
'floating-vue/nuxt', 'floating-vue/nuxt',
'@nuxt/eslint',
], ],
nitro: { nitro: {

View file

@ -7,7 +7,12 @@
"dev": "nuxt dev --host", "dev": "nuxt dev --host",
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck",
"lint": "eslint . --max-warnings 50",
"lint:fix": "eslint . --fix --max-warnings 50",
"test": "vitest run",
"test_watch": "vitest"
}, },
"web-types": "./web-types.json", "web-types": "./web-types.json",
"dependencies": { "dependencies": {
@ -39,5 +44,12 @@
"strip-ansi": "7.1.0", "strip-ansi": "7.1.0",
"ansi-styles": "6.2.0", "ansi-styles": "6.2.0",
"ansi-regex": "6.2.0" "ansi-regex": "6.2.0"
},
"devDependencies": {
"@nuxt/eslint": "^0.7.4",
"@types/node": "^22.7.8",
"@typescript-eslint/parser": "^8.43.0",
"vitest": "^3.2.4",
"vue-eslint-parser": "^10.2.0"
} }
} }

File diff suppressed because it is too large Load diff

25
ui/test_fix.mjs Normal file
View file

@ -0,0 +1,25 @@
import { match_str } from './app/utils/ytdlp'
const testData = {
"age_limit": 0,
"comment_count": 6,
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
"uploader_url": "https://www.youtube.com/@PlayFramePlus"
}
// The filter that was incorrectly matching before
const filter = "channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only"
console.log('Test data:', testData)
console.log('Filter:', filter)
console.log('Result:', match_str(filter, testData))
console.log('Expected: false')
// Test individual parts
console.log('\nTesting parts individually:')
console.log('Part 1 (channel_id = \'UCfmrcEdes7yDtEISGPM1T-A\'):', match_str("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A'", testData))
console.log('Part 2 (availability = subscriber_only):', match_str("availability = subscriber_only", testData))
// Test correct channel_id
console.log('\nTesting with correct channel_id:')
console.log('Correct channel_id:', match_str("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'", testData))