improved match_str by implementing extended subset.
This commit is contained in:
parent
a39a009395
commit
fae1d7c577
30 changed files with 4758 additions and 251 deletions
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
297
app/library/mini_filter.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
481
app/tests/mini_filter_test.py
Normal file
481
app/tests/mini_filter_test.py
Normal 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
1019
app/tests/test_utils.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
35
ui/.prettierrc
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ const defaultTitle = computed(() => {
|
|||
return 'Confirm'
|
||||
case 'prompt':
|
||||
return 'Input required'
|
||||
default:
|
||||
return 'Dialog'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = ''" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
238
ui/app/utils/ytdlp.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
55
ui/eslint.config.js
Normal 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'
|
||||
}
|
||||
})
|
||||
|
|
@ -60,6 +60,7 @@ export default defineNuxtConfig({
|
|||
'@pinia/nuxt',
|
||||
'@vueuse/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
'@nuxt/eslint',
|
||||
],
|
||||
|
||||
nitro: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2385
ui/pnpm-lock.yaml
2385
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
25
ui/test_fix.mjs
Normal file
25
ui/test_fix.mjs
Normal 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))
|
||||
Loading…
Reference in a new issue