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

View file

@ -16,9 +16,10 @@ from pathlib import Path
from typing import Any, TypeVar
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 .mini_filter import match_str
from .ytdlp import YTDLP
LOG: logging.Logger = logging.getLogger("Utils")

View file

@ -10,6 +10,7 @@ from aiohttp import web
from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .mini_filter import match_str
from .Singleton import Singleton
from .Utils import arg_converter, init_class
@ -183,8 +184,6 @@ class Conditions(metaclass=Singleton):
bool: True if valid, False otherwise.
"""
from yt_dlp.utils import match_str
if not isinstance(item, dict):
if not isinstance(item, Condition):
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:
return None
from yt_dlp.utils import match_str
for item in self.get_all():
if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.")
@ -343,6 +340,4 @@ class Conditions(metaclass=Singleton):
if not item or not item.filter:
return None
from yt_dlp.utils import match_str
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.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.mini_filter import match_str
from app.library.router import route
from app.library.Utils import extract_info, init_class, validate_uuid
from app.library.YTDLPOpts import YTDLPOpts
@ -181,8 +182,6 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
)
try:
from yt_dlp.utils import match_str
status = match_str(cond, data)
except Exception as 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"
[project.optional-dependencies]
installer = [
"pyinstaller",
]
installer = ["pyinstaller"]
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
@ -200,3 +198,8 @@ docstring-code-line-length = "dynamic"
[tool.uv]
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="icon"><i class="fa-solid fa-info" /></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"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution.
@ -454,7 +454,9 @@ const logic_test = computed(() => {
}
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) {
console.error(e)
return false

View file

@ -270,7 +270,7 @@ const validateItem = (item: DLField, index: number): boolean => {
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.`)
return false
}
@ -278,6 +278,7 @@ const validateItem = (item: DLField, index: number): boolean => {
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)))
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions

View file

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

View file

@ -1,9 +1,9 @@
<template>
<vTooltip @show="loadContent" @hide="stopTimer">
<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>
<template v-else>
<div v-else>
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
<div class="is-block" style="word-break: all;" v-if="props.title">
<span style="font-size: 120%;">{{ props.title }}</span>
@ -14,7 +14,7 @@
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
</figure>
</div>
</template>
</div>
</template>
</vTooltip>
</template>

View file

@ -504,7 +504,7 @@ const dialog_confirm = ref<{
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 closeVideo = () => { video_item.value = null }

View file

@ -39,13 +39,13 @@
: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.' : ''">
<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">
{{ item.name }}
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
{{ cPreset.name }}
</option>
</optgroup>
<optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
{{ item.name }}
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
{{ dPreset.name }}
</option>
</optgroup>
</select>
@ -127,7 +127,7 @@
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></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"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution.
@ -238,7 +238,6 @@ const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
(e: 'clear_form'): void
(e: 'remove_archive', url: string): void
}>()
const config = useConfigStore()
const socket = useSocketStore()
@ -303,7 +302,7 @@ const addDownload = async () => {
continue
}
const keyRegex = new RegExp(`(^|\s)${key}(\s|$)`);
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
if (form_cli && keyRegex.test(form_cli)) {
continue;
}
@ -514,5 +513,6 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
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)))
</script>

View file

@ -100,8 +100,8 @@
<div class="control has-icons-left">
<div class="select is-fullwidth">
<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">
{{ item }}
<option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod">
{{ rMethod }}
</option>
</select>
</div>
@ -125,8 +125,8 @@
<div class="control has-icons-left">
<div class="select is-fullwidth">
<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">
{{ ucFirst(item) }}
<option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType">
{{ ucFirst(rType) }}
</option>
</select>
</div>
@ -153,8 +153,8 @@
<div class="control has-icons-left">
<div class="select is-multiple is-fullwidth">
<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">
{{ item }}
<option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent">
{{ aEvent }}
</option>
</select>
</div>
@ -183,13 +183,13 @@
<div class="select is-multiple is-fullwidth">
<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">
<option v-for="item in filter_presets(false)" :key="item.id" :value="item.name">
{{ item.name }}
<option v-for="cPreset in filter_presets(false)" :key="cPreset.id" :value="cPreset.name">
{{ cPreset.name }}
</option>
</optgroup>
<optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.id" :value="item.name">
{{ item.name }}
<option v-for="dPreset in filter_presets(true)" :key="dPreset.id" :value="dPreset.name">
{{ dPreset.name }}
</option>
</optgroup>
</select>

View file

@ -154,7 +154,7 @@
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></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"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution.

View file

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

View file

@ -242,7 +242,7 @@
<span class="help is-bold">
<span class="icon"><i class="fa-solid fa-info" /></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"
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
are ignored</NuxtLink>. Use with caution.

View file

@ -5,7 +5,7 @@
<nav class="navbar is-mobile is-dark">
<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'">
<span class="is-text-overflow">
<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-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">
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
<span>Files</span>
</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>Presets</span>
</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>Tasks</span>
</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"><i class="fa-solid fa-paper-plane" /></span>
<span>Notifications</span>
</span>
</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>Conditions</span>
</NuxtLink>
@ -62,13 +62,13 @@
</a>
<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">
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
<span>Logs</span>
</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">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>Console</span>

View file

@ -111,7 +111,7 @@
<NewDownload v-if="config.showForm || config.app.basic_mode"
@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)"
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
@clear_search="query = ''" />

View file

@ -78,7 +78,7 @@
<td class="is-text-overflow is-vcentered">
<div class="is-bold">
{{ 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 class="is-unselectable">
<span class="icon-text">
@ -150,7 +150,7 @@
</p>
<p v-if="item.request?.headers && item.request.headers.length > 0">
<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>
</div>
</div>

View file

@ -780,7 +780,7 @@ const exportItem = async (item: task_item) => {
const get_tags = (name: string): Array<string> => {
const regex = /\[(.*?)\]/g;
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();

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+)?/;
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 {
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 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 numStr = m.groups.num.replace(",", ".");
const unit = m.groups.unit;
const mult = unitTable[unit];
if (undefined === mult) {
return null;
}
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, bytes: 1, b: 1,
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;
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;
}
}
const durationRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
const m = s.match(durationRe);
if (!m?.groups) {
return null;
// 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(":", "."))
);
}
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;
}
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> = {
'*=': (a, b) => a.includes(b),
'^=': (a, b) => a.startsWith(b),
'$=': (a, b) => a.endsWith(b),
'~=': (a, b) => new RegExp(b).test(a),
"*=": (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,
"*=": 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 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(`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+))$`);
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, noneInclusive, quote, quoted, plain } = m.groups;
const { key, negation, op, quote, quoted, plain } = m.groups;
const unnegatedOp = COMPARISON_OPERATORS[op!];
if (!unnegatedOp) {
throw new Error(`Invalid operator '${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 value = quoted ?? plain ?? '';
if (quote) {
value = value.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
}
if (!(key! in dct)) {
return isIncomplete(key!) || Boolean(noneInclusive);
}
let processedValue = value;
if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, "g"), quote);
const actual = dct[key!];
let numeric: number | null = null;
if (actual === undefined || actual === null) {
return false;
}
if (typeof actual === 'number') {
numeric = Number(value);
if (Number.isNaN(numeric)) {
numeric = parse_filesize(value) ?? parse_filesize(`${value}B`) ?? parse_duration(value);
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(actual, numeric !== null ? numeric : value);
return opFn(finalActual, numeric !== null ? numeric : processedValue);
}
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 unaryRe = new RegExp(`^(?<op>!?)\\s*(?<key>[a-z_]+)$`);
// unary operator
const unaryRe = /^(?<op>!?)\s*(?<key>[a-z_]+)$/;
const mu = filterPart.trim().match(unaryRe);
if (mu?.groups) {
const { op, key } = mu.groups;
if (!(key! in dct)) {
return isIncomplete(key!);
}
const actual = dct[key!];
const unaryOp = UNARY_OPERATORS[op!];
if (!unaryOp) {
throw new Error(`Invalid unary operator '${op}'`);
}
return unaryOp(actual);
return UNARY_OPERATORS[op!]!(actual);
}
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',
'@vueuse/nuxt',
'floating-vue/nuxt',
'@nuxt/eslint',
],
nitro: {

View file

@ -7,7 +7,12 @@
"dev": "nuxt dev --host",
"generate": "nuxt generate",
"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",
"dependencies": {
@ -39,5 +44,12 @@
"strip-ansi": "7.1.0",
"ansi-styles": "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))