Merge pull request #416 from arabcoders/dev

Mostly CI improvements to include tests and linting
This commit is contained in:
Abdulmohsen 2025-09-12 03:02:03 +03:00 committed by GitHub
commit 049b9a4e38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 9217 additions and 649 deletions

View file

@ -1,4 +1,4 @@
name: Test Build PR
name: Test PR
permissions:
contents: read
@ -14,14 +14,37 @@ on:
env:
PNPM_VERSION: 10
NODE_VERSION: 20
PYTHON_VERSION: "3.11"
jobs:
build-pr:
test:
name: Run Tests & Build Validation
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Python setup and testing
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install Python dependencies
run: uv sync
- name: Run Python linting
run: uv run ruff check app/
- name: Run Python tests
run: uv run pytest app/tests/ -v --tb=short
# Frontend setup and testing
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@ -34,16 +57,39 @@ jobs:
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml"
- name: Install frontend dependencies & Build
- name: Install frontend dependencies
working-directory: ui
run: |
pnpm install --production --prefer-offline --frozen-lockfile
pnpm run generate
env:
NODE_ENV: development
run: pnpm install --frozen-lockfile
- name: Prepare frontend (nuxt prepare)
working-directory: ui
env:
NODE_ENV: development
run: pnpm nuxt prepare
- name: Run frontend linting
working-directory: ui
run: pnpm run lint
- name: Run frontend type checking
working-directory: ui
run: pnpm run typecheck
- name: Run frontend tests
working-directory: ui
run: pnpm run test:ci
- name: Build frontend
working-directory: ui
run: pnpm run generate
# Quick Docker build validation (no push, cache only)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build commit to check (amd64 only)
- name: Validate Docker build
uses: docker/build-push-action@v5
with:
context: .
@ -52,4 +98,3 @@ jobs:
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
provenance: false

View file

@ -26,29 +26,41 @@ env:
GHCR_SLUG: ghcr.io/arabcoders/ytptube
PNPM_VERSION: 10
NODE_VERSION: 20
PYTHON_VERSION: "3.11"
jobs:
docker-build-arch:
name: Build Container (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-latest
arch: arm64
test:
name: Run Tests & Prepare Frontend
permissions:
packages: write
contents: write
env:
ARCH: ${{ matrix.arch }}
contents: read
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Python setup and testing
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install Python dependencies
run: uv sync
- name: Run Python linting
run: uv run ruff check app/
- name: Run Python tests
run: uv run pytest app/tests/ -v --tb=short
# Frontend setup and testing
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
@ -61,12 +73,41 @@ jobs:
cache: pnpm
cache-dependency-path: "ui/pnpm-lock.yaml"
- name: Install frontend dependencies & Build
- name: Install frontend dependencies
working-directory: ui
run: |
pnpm install --production --prefer-offline --frozen-lockfile
pnpm run generate
env:
NODE_ENV: development
run: pnpm install --frozen-lockfile
- name: Prepare frontend (nuxt prepare)
working-directory: ui
env:
NODE_ENV: development
run: pnpm nuxt prepare
- name: Run frontend linting
working-directory: ui
run: pnpm run lint
- name: Run frontend type checking
working-directory: ui
run: pnpm run typecheck
- name: Run frontend tests
working-directory: ui
run: pnpm run test:ci
- name: Remove dev dependencies
working-directory: ui
env:
NODE_ENV: production
run: pnpm install --frozen-lockfile --prod
- name: Build frontend
working-directory: ui
run: pnpm run generate
# Generate changelog and version files
- name: Install GitPython
run: pip install gitpython
@ -93,6 +134,42 @@ jobs:
-e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \
app/library/version.py
# Upload built frontend as artifact for docker builds
- name: Upload frontend build
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: ui/exported/
retention-days: 1
docker-build-arch:
name: Build Container (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
needs: [test]
strategy:
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-latest
arch: arm64
permissions:
packages: write
contents: write
env:
ARCH: ${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download frontend build
uses: actions/download-artifact@v4
with:
name: frontend-build
path: ui/exported/
- name: Docker meta
id: meta
uses: docker/metadata-action@v5

2
.gitignore vendored
View file

@ -37,3 +37,5 @@ __pycache__
dist
build
version.txt
test_impl.py
./eslint.config.js

2
.vscode/launch.json vendored
View file

@ -87,6 +87,6 @@
}
],
"justMyCode": true
}
},
]
}

33
.vscode/settings.json vendored
View file

@ -12,12 +12,14 @@
],
"cSpell.words": [
"aconvert",
"addopts",
"ahas",
"ahash",
"aiocron",
"anyio",
"Archiver",
"arrowless",
"asyncio",
"attl",
"autonumber",
"bgutil",
@ -26,6 +28,7 @@
"brainicism",
"brotlicffi",
"buildcache",
"Cfmrc",
"choco",
"consoletitle",
"continuedl",
@ -39,20 +42,27 @@
"daterange",
"defusedxml",
"dlfields",
"dotdot",
"dotenv",
"dpkg",
"dunder",
"Edes",
"edgechromium",
"EISGPM",
"engineio",
"Errno",
"esac",
"euuo",
"excepthook",
"faststart",
"Fetc",
"finaldir",
"flac",
"forcejson",
"forceprint",
"Fpasswd",
"fribidi",
"Funsafe",
"getpid",
"gibibytes",
"gitpython",
@ -62,12 +72,14 @@
"httpx",
"imagetools",
"kibibytes",
"lastgroup",
"levelno",
"libcurl",
"libjavascriptcoregtk",
"libstdc",
"libwebkit",
"libx",
"LPAREN",
"matchtitle",
"matroska",
"mbed",
@ -89,6 +101,7 @@
"noprogress",
"onefile",
"pathex",
"pickleable",
"platformdirs",
"playlistend",
"playlistrandom",
@ -102,10 +115,12 @@
"pycryptodomex",
"pyinstaller",
"pypi",
"pythonpath",
"quicktime",
"rejecttitle",
"remux",
"reqs",
"RPAREN",
"rtime",
"rvfc",
"SIGUSR",
@ -113,11 +128,14 @@
"socketio",
"softprops",
"sstr",
"startswith",
"SUPPRESSHELP",
"tebibytes",
"testpaths",
"tiktok",
"timespec",
"tmpfilename",
"toplevel",
"ungroup",
"unmark",
"unnegated",
@ -130,20 +148,17 @@
"ustr",
"vcodec",
"vconvert",
"Vitest",
"writedescription",
"xerror",
"youtu",
"youtubepot"
"youtubepot",
"русский",
"العربية"
],
"css.styleSheets": [
"ui/app/assets/css/*.css"
],
"spellright.language": [
"en"
],
"spellright.documentTypes": [
"latex"
],
"search.exclude": {
"ui/.nuxt": true,
"ui/.output": true,
@ -152,5 +167,7 @@
"ui/node_modules": true,
"ui/.out": true,
"**/.venv": true,
}
},
"eslint.format.enable": true,
"eslint.ignoreUntitled": true
}

View file

@ -1,4 +1,3 @@
import asyncio
import copy
import json
import logging
@ -151,7 +150,7 @@ class DataStore:
if "error" == value.info.status and not no_notify:
from app.library.Events import EventBus, Events
asyncio.create_task(EventBus.get_instance().emit(Events.ITEM_ERROR, value.info), name="emit_item_error")
EventBus.get_instance().emit(Events.ITEM_ERROR, value.info)
self._dict.update({value.info._id: value})
self._update_store_item(self._type, value.info)

View file

@ -350,7 +350,7 @@ class Download:
self.proc.start()
self.info.status = "preparing"
await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
asyncio.create_task(self.progress_update(), name=f"update-{self.id}")
ret = await asyncio.get_running_loop().run_in_executor(None, self.proc.join)
@ -516,7 +516,7 @@ class Download:
self.logger.debug(f"Status Update: {self.info._id=} {status=}")
if isinstance(status, str):
await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
return
self.tmpfilename: str | None = status.get("tmpfilename")
@ -547,7 +547,7 @@ class Download:
if "error" == self.info.status and "error" in status:
self.info.error = status.get("error")
await self._notify.emit(
self._notify.emit(
Events.LOG_ERROR,
data=self.info,
title="Download Error",
@ -584,7 +584,7 @@ class Download:
self.logger.error(f"Failed to run ffprobe. {status.get}. {e}")
if not self.final_update or fl:
await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
async def progress_update(self):
"""

View file

@ -3,6 +3,7 @@ import functools
import glob
import logging
import time
import traceback
import uuid
from datetime import UTC, datetime, timedelta
from email.utils import formatdate
@ -107,9 +108,11 @@ class DownloadQueue(metaclass=Singleton):
_ (web.Application): The application to attach the download queue to.
"""
self._notify.subscribe(
Events.STARTED, lambda _, __: self.initialize(), f"{__class__.__name__}.{__class__.initialize.__name__}"
)
async def event_handler(_, __):
await self.initialize()
self._notify.subscribe(Events.STARTED, event_handler, f"{__class__.__name__}.{__class__.initialize.__name__}")
Scheduler.get_instance().add(
timer="* * * * *",
@ -155,7 +158,6 @@ class DownloadQueue(metaclass=Singleton):
"""
status: dict[str, str] = {"status": "ok"}
started = False
tasks: list = []
for item_id in ids:
try:
@ -173,14 +175,12 @@ class DownloadQueue(metaclass=Singleton):
item.info.auto_start = True
updated: Download = self.queue.put(item)
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
tasks.append(
self._notify.emit(
Events.ITEM_RESUMED,
data=item.info,
title="Download Resumed",
message=f"Download '{item.info.title}' has been resumed.",
)
self._notify.emit(Events.ITEM_UPDATED, data=updated.info)
self._notify.emit(
Events.ITEM_RESUMED,
data=item.info,
title="Download Resumed",
message=f"Download '{item.info.title}' has been resumed.",
)
status[item_id] = "started"
started = True
@ -189,9 +189,6 @@ class DownloadQueue(metaclass=Singleton):
if started:
self.event.set()
if len(tasks) > 0:
await asyncio.gather(*tasks)
return status
async def pause_items(self, ids: list[str]) -> dict[str, str]:
@ -206,7 +203,6 @@ class DownloadQueue(metaclass=Singleton):
"""
status: dict[str, str] = {"status": "ok"}
tasks: list = []
for item_id in ids:
try:
@ -229,21 +225,16 @@ class DownloadQueue(metaclass=Singleton):
item.info.auto_start = False
updated: Download = self.queue.put(item)
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
tasks.append(
self._notify.emit(
Events.ITEM_PAUSED,
data=item.info,
title="Download Paused",
message=f"Download '{item.info.title}' has been paused.",
)
self._notify.emit(Events.ITEM_UPDATED, data=updated.info)
self._notify.emit(
Events.ITEM_PAUSED,
data=item.info,
title="Download Paused",
message=f"Download '{item.info.title}' has been paused.",
)
status[item_id] = "paused"
LOG.debug(f"Item {item.info.name()} marked as paused.")
if len(tasks) > 0:
await asyncio.gather(*tasks)
return status
def pause(self, shutdown: bool = False) -> bool:
@ -495,7 +486,7 @@ class DownloadQueue(metaclass=Singleton):
dlInfo.info.status = "not_live"
dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "")
await self._notify.emit(
self._notify.emit(
Events.LOG_INFO,
data={"preset": dlInfo.info.preset, "lowPriority": True},
title=nTitle,
@ -517,7 +508,7 @@ class DownloadQueue(metaclass=Singleton):
dlInfo.info.status = "error"
itemDownload = self.done.put(dlInfo)
await self._notify.emit(
self._notify.emit(
Events.LOG_WARNING,
data={"preset": dlInfo.info.preset, "logs": text_logs},
title=nTitle,
@ -557,7 +548,7 @@ class DownloadQueue(metaclass=Singleton):
nStore = "history"
nEvent = Events.ITEM_MOVED
nTitle = "Premiering right now"
await self._notify.emit(
self._notify.emit(
Events.LOG_INFO, data={"preset": dlInfo.info.preset}, title=nTitle, message=nMessage
)
else:
@ -570,7 +561,7 @@ class DownloadQueue(metaclass=Singleton):
else:
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
await self._notify.emit(
self._notify.emit(
nEvent,
data={"to": nStore, "preset": itemDownload.info.preset, "item": itemDownload.info}
if Events.ITEM_MOVED == nEvent
@ -702,7 +693,7 @@ class DownloadQueue(metaclass=Singleton):
self.done.put(dlInfo)
await self._notify.emit(
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
title="Download History Update",
@ -712,7 +703,7 @@ class DownloadQueue(metaclass=Singleton):
message: str = f"The URL '{item.url}' is already downloaded and recorded in archive."
LOG.error(message)
await self._notify.emit(
self._notify.emit(
Events.LOG_INFO, data={"preset": item.preset}, title="Already Downloaded", message=message
)
@ -866,7 +857,7 @@ class DownloadQueue(metaclass=Singleton):
await item.close()
LOG.debug(f"Deleting from queue {item_ref}")
self.queue.delete(id)
await self._notify.emit(
self._notify.emit(
Events.ITEM_CANCELLED,
data=item.info,
title="Download Cancelled",
@ -874,7 +865,7 @@ class DownloadQueue(metaclass=Singleton):
)
item.info.status = "cancelled"
self.done.put(item)
await self._notify.emit(
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": item.info.preset, "item": item.info},
title="Download Cancelled",
@ -949,7 +940,7 @@ class DownloadQueue(metaclass=Singleton):
self.done.delete(id)
_status: str = "Removed" if removed_files > 0 else "Cleared"
await self._notify.emit(
self._notify.emit(
Events.ITEM_DELETED,
data=item.info,
title=f"Download {_status}",
@ -1086,7 +1077,6 @@ class DownloadQueue(metaclass=Singleton):
await entry.close()
if self.queue.exists(key=id):
_tasks = []
LOG.debug(f"Download Task '{id}' is completed. Removing from queue.")
self.queue.delete(key=id)
@ -1096,7 +1086,7 @@ class DownloadQueue(metaclass=Singleton):
if entry.is_cancelled() is True:
nTitle = "Download Cancelled"
nMessage = f"Cancelled '{entry.info.title}' download."
await self._notify.emit(Events.ITEM_CANCELLED, data=entry.info, title=nTitle, message=nMessage)
self._notify.emit(Events.ITEM_CANCELLED, data=entry.info, title=nTitle, message=nMessage)
entry.info.status = "cancelled"
if entry.info.status == "finished" and entry.info.filename:
@ -1105,19 +1095,15 @@ class DownloadQueue(metaclass=Singleton):
if entry.info.is_archivable and not entry.info.is_archived:
entry.info.is_archived = True
_tasks.append(self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage))
self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage)
self.done.put(entry)
_tasks.append(
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": entry.info.preset, "item": entry.info},
title=nTitle,
message=nMessage,
)
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": entry.info.preset, "item": entry.info},
title=nTitle,
message=nMessage,
)
await asyncio.gather(*_tasks)
else:
LOG.warning(f"Download '{id}' not found in queue.")
@ -1223,7 +1209,6 @@ class DownloadQueue(metaclass=Singleton):
return
if exc := task.exception():
import traceback
task_name: str = task.get_name() if task.get_name() else "unknown_task"
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {traceback.format_exc()}")

View file

@ -145,6 +145,9 @@ class Event:
data: Any
"""The data that was passed to the event."""
extras: dict = field(default_factory=dict)
"""Listeners can add extra data to the event."""
def serialize(self) -> dict:
"""
Serialize the event.
@ -162,9 +165,20 @@ class Event:
"data": self.data,
}
def __repr__(self):
def __repr__(self) -> str:
return f"Event(id={self.id}, created_at={self.created_at}, event={self.event}, title={self.title}, message={self.message} data={self.data})"
def put(self, key: str, value: Any) -> None:
"""
Put extra data to the event.
Args:
key (str): The key of the extra data.
value (Any): The value of the extra data.
"""
self.extras[key] = value
def datatype(self) -> str:
"""
Get the datatype of the data.
@ -210,17 +224,12 @@ class EventBus(metaclass=Singleton):
debug: bool = False
"""Whether to log debug messages or not."""
_offload: BackgroundWorker
_offload: BackgroundWorker = None
"""The background worker to offload tasks to."""
def __init__(self):
EventBus._instance = self
from .config import Config
self.debug = Config.get_instance().debug
self._offload = BackgroundWorker.get_instance()
@staticmethod
def get_instance() -> "EventBus":
"""
@ -305,84 +314,11 @@ class EventBus(metaclass=Singleton):
return self
def sync_emit(
self,
event: str,
data: Any | None = None,
title: str | None = None,
message: str | None = None,
loop=None,
wait: bool = True,
**kwargs,
):
"""
Emit event and (optionally) wait for results.
Args:
event (str): The event to emit.
data (Any|None): The data to pass to the event.
title (str | None): The title of the event, if any.
message (str | None): The message of the event, if any.
loop (asyncio.AbstractEventLoop | None): The event loop to use. If None, the current running loop is used.
wait (bool): Whether to wait for the results of the event handlers. Defaults to True.
**kwargs: Additional keyword arguments to pass to the event
Returns:
list: The results are the return values of the coroutines. If the coroutine raises an exception,
the exception is caught and logged. If event does not exist, an empty list is returned.
If wait is False, a list of asyncio.Task objects is returned instead if we are in a running event loop,
or the result of the coroutine if we are not in a running event loop.
"""
if event not in self._listeners:
return []
if not data:
data = {}
async def emit_all():
ev = Event(event=event, title=title, message=message, data=data)
LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
res: list = []
for h in self._listeners[event].values():
try:
res.append(await h.handle(ev, **kwargs))
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to emit event '{event}' to '{h.name}'. Error message '{e!s}'.")
return res
try:
loop = loop or asyncio.get_running_loop()
in_same_loop: bool = asyncio.get_running_loop() is loop
except RuntimeError:
loop = None
in_same_loop = False
if loop is None or not loop.is_running():
return asyncio.run(emit_all())
if in_same_loop:
if wait:
msg = (
"Calling EventsBus.sync_emit(...,wait=True) from within the running event loop would cause dead-lock. "
"Use `await EventsBus.emit(...)` or `EventsBus.sync_emit(..., wait=False)`."
)
raise RuntimeError(msg)
return loop.create_task(emit_all())
fut = asyncio.run_coroutine_threadsafe(emit_all(), loop)
return fut.result() if wait else fut
async def emit(
def emit(
self, event: str, data: Any | None = None, title: str | None = None, message: str | None = None, **kwargs
) -> Awaitable:
) -> None:
"""
Emit an event.
Emit an event to all registered listeners.
Args:
event (str): The event to emit.
@ -391,44 +327,6 @@ class EventBus(metaclass=Singleton):
message (str | None): The message of the event, if any.
**kwargs: The keyword arguments to pass to the event.
Returns:
Awaitable: The task that was created to run the event.
"""
if event not in self._listeners:
return []
if not data:
data = {}
ev = Event(event=event, title=title, message=message, data=data)
if self.debug or event not in Events.only_debug():
LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
tasks = []
for handler in self._listeners[event].values():
try:
tasks.append(handler.handle(ev, **kwargs))
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
return asyncio.gather(*tasks)
def offload(
self, event: str, title: str | None = None, message: str | None = None, data: Any | None = None, **kwargs
) -> None:
"""
Offload an dispatching event to a background worker.
Args:
event (str): The event to offload.
data (Any|None): The data to pass to the event.
title (str | None): The title of the event, if any.
message (str | None): The message of the event, if any.
**kwargs: Additional keyword arguments to pass to the event.
"""
if event not in self._listeners:
return
@ -439,11 +337,57 @@ class EventBus(metaclass=Singleton):
ev = Event(event=event, title=title, message=message, data=data)
if self.debug or event not in Events.only_debug():
LOG.debug(f"Offloading event '{ev.id}: {ev.event}'.", extra={"data": data})
LOG.debug(f"Emitting event '{ev.id}: {ev.event}'.", extra={"data": data})
for handler in self._listeners[event].values():
try:
self._offload.submit(handler.handle, ev, **kwargs)
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to offload event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
try:
loop = asyncio.get_running_loop()
for handler in self._listeners[event].values():
try:
if handler.is_coroutine:
coro = handler.call_back(ev, handler.name, **kwargs)
if asyncio.iscoroutine(coro):
loop.create_task(coro)
else:
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
else:
loop.create_task(self._call(handler, ev, kwargs), name=f"sync-handler-{handler.name}-{ev.id}")
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
except RuntimeError:
LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
for handler in self._listeners[event].values():
try:
if not self._offload:
self._offload = BackgroundWorker.get_instance()
self._offload.submit(handler.handle, ev, **kwargs)
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
def clear(self) -> None:
"""
Clear all listeners. Useful for testing.
"""
self._listeners.clear()
def debug_enable(self) -> None:
"""
Enable debug logging.
"""
self.debug = True
def debug_disable(self) -> None:
"""
Disable debug logging.
"""
self.debug = False
@staticmethod
def _call(h, event, kw):
async def call_handler():
return h.call_back(event, h.name, **kw)
return call_handler()

View file

@ -52,10 +52,10 @@ class HttpSocket:
ping_timeout=5,
)
encoder = encoder or Encoder()
self.rootPath = root_path
self.rootPath: Path = root_path
def emit(e: Event, _, **kwargs):
return self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
async def event_handler(e: Event, _, **kwargs):
await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
services = Services.get_instance()
services.add_all(
@ -73,7 +73,7 @@ class HttpSocket:
}
)
self._notify.subscribe("frontend", emit, f"{__class__.__name__}.emit")
self._notify.subscribe("frontend", event_handler, f"{__class__.__name__}.emit")
@staticmethod
def ws_event(func): # type: ignore
@ -101,11 +101,12 @@ class HttpSocket:
app.on_shutdown.append(self.on_shutdown)
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
self._notify.subscribe(
Events.ADD_URL,
lambda data, _, **kwargs: self.queue.add(item=Item.format(data.data)), # noqa: ARG005
f"{__class__.__name__}.add",
)
async def event_handler(data: Event, _):
if data and data.data:
await self.queue.add(item=Item.format(data.data))
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
load_modules(self.rootPath, self.rootPath / "routes" / "socket")

View file

@ -505,13 +505,12 @@ class Notification(metaclass=Singleton):
LOG.error(f"Error sending Notification event '{ev.event}: {ev.id}' to '{target.name}'. '{err_msg!s}'.")
return {"url": target.request.url, "status": 500, "text": str(ev)}
def emit(self, e: Event, _, **__):
def emit(self, e: Event, _, **__) -> None:
if len(self._targets) < 1 or not NotificationEvents.is_valid(e.event):
return self.noop()
return
self._offload.submit(self.send, e)
return self.noop()
return
def _deep_unpack(self, data: dict) -> dict:
for k, v in data.items():

View file

@ -136,7 +136,7 @@ class Presets(metaclass=Singleton):
LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
continue
def event_handler(_, __):
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)

View file

@ -26,11 +26,11 @@ class Scheduler(metaclass=Singleton):
self._loop = loop or asyncio.get_event_loop()
EventBus.get_instance().subscribe(
Events.SCHEDULE_ADD,
lambda data, _, **kwargs: self.add(**data.data), # noqa: ARG005
f"{__class__.__name__}.add",
)
async def event_handler(data, _):
if data and data.data:
self.add(**data.data)
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add")
@staticmethod
def get_instance() -> "Scheduler":

View file

@ -193,11 +193,12 @@ class Tasks(metaclass=Singleton):
"""
self.load()
self._notify.subscribe(
Events.TASKS_ADD,
lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005
f"{__class__.__name__}.add",
)
async def event_handler(data, _):
if data and data.data:
self.save(data.data)
self._notify.subscribe(Events.TASKS_ADD, event_handler, f"{__class__.__name__}.add")
self._task_handler.load()
def get_all(self) -> list[Task]:
@ -449,7 +450,7 @@ class Tasks(metaclass=Singleton):
await asyncio.gather(*_tasks)
except Exception as e:
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
await self._notify.emit(
self._notify.emit(
Events.LOG_ERROR,
data={"preset": task.preset},
title="Task failed",

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")
@ -225,38 +226,116 @@ def extract_info(
return YTDLP.sanitize_info(data) if sanitize_info else data
def merge_dict(source: dict, destination: dict) -> dict:
def _is_safe_key(key: any) -> bool:
"""
Merge data from source into destination safely.
Check if a dictionary key is safe for merging.
Blocks:
- All dunder attributes (__*__)
- Non-string keys (for consistency)
- Keys that could be dangerous variations
"""
# Only allow string keys
if not isinstance(key, str):
return False
# Block empty keys and whitespace-only keys
if not key or not key.strip():
return False
# Block only truly dangerous dunder patterns
key_stripped = key.strip()
# Block dunder attributes (starts AND ends with __)
return not (key_stripped.startswith("__") and key_stripped.endswith("__"))
def merge_dict(
source: dict, destination: dict, max_depth: int = 50, max_list_size: int = 10000, _depth: int = 0, _seen: set = None
) -> dict:
"""
Merge data from source into destination safely with protection against DoS attacks.
Args:
source (dict): Source data
destination (dict): Destination data
max_depth (int): Maximum recursion depth allowed (default: 50)
max_list_size (int): Maximum list size allowed (default: 10000)
_depth (int): Internal recursion depth counter
_seen (set): Internal circular reference tracker
Returns:
dict: The merged dictionary
Raises:
TypeError: If source or destination are not dictionaries
RecursionError: If recursion depth exceeds safety limit
ValueError: If circular reference is detected
"""
if not isinstance(source, dict) or not isinstance(destination, dict):
msg = "Both source and destination must be dictionaries."
raise TypeError(msg)
destination_copy = copy.deepcopy(destination)
# Prevent deep recursion DoS
if _depth > max_depth:
msg = f"Recursion depth limit exceeded ({max_depth})"
raise RecursionError(msg)
# Initialize circular reference tracking
if _seen is None:
_seen = set()
# Check for circular references
source_id = id(source)
dest_id = id(destination)
if source_id in _seen or dest_id in _seen:
msg = "Circular reference detected"
raise ValueError(msg)
# Track current objects
current_seen = _seen | {source_id, dest_id}
# Create a clean copy of destination with only safe keys
destination_copy = {}
for k, v in destination.items():
if _is_safe_key(k):
# Prevent memory DoS from large lists
if isinstance(v, list) and len(v) > max_list_size:
destination_copy[k] = v[:max_list_size] # Truncate large lists
else:
destination_copy[k] = copy.deepcopy(v)
for key, value in source.items():
if key in {"__class__", "__dict__", "__globals__", "__builtins__"}:
# Skip unsafe keys
if not _is_safe_key(key):
continue
destination_value = destination_copy.get(key)
# Recursively merge dictionaries
# Recursively merge dictionaries with safety checks
if isinstance(value, dict) and isinstance(destination_value, dict):
destination_copy[key] = merge_dict(value, destination_value)
destination_copy[key] = merge_dict(
value, destination_value, max_depth, max_list_size, _depth + 1, current_seen
)
# Safely extend lists without reference issues
# Safely extend lists with size limits
elif isinstance(value, list) and isinstance(destination_value, list):
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value)
# Prevent memory DoS
combined_size = len(value) + len(destination_value)
if combined_size > max_list_size:
# Truncate to stay within limits
available_space = max_list_size - len(destination_value)
truncated_value = value[: max(0, available_space)]
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(truncated_value)
else:
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value)
# For non-dict values or when destination doesn't have the key
elif isinstance(value, dict):
destination_copy[key] = merge_dict(value, {}, max_depth, max_list_size, _depth + 1, current_seen)
elif isinstance(value, list) and len(value) > max_list_size:
# Truncate large lists
destination_copy[key] = copy.deepcopy(value[:max_list_size])
else:
destination_copy[key] = copy.deepcopy(value)

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
@ -67,7 +68,7 @@ class Conditions(metaclass=Singleton):
except Exception:
pass
def event_handler(_, __):
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
@ -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

View file

@ -122,7 +122,7 @@ class DLFields(metaclass=Singleton):
except Exception:
pass
def event_handler(_, __):
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)

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

@ -26,11 +26,11 @@ class BaseHandler:
if "failure_count" not in cls.__dict__:
cls.failure_count = {}
EventBus.get_instance().subscribe(
Events.ITEM_ERROR,
lambda data, _, **__: cls.on_error(data.data),
f"{cls.__name__}.on_error",
)
async def event_handler(data, _):
if data and data.data:
await cls.on_error(data.data)
EventBus.get_instance().subscribe(Events.ITEM_ERROR, event_handler, f"{cls.__name__}.on_error")
@staticmethod
def can_handle(task: Task) -> bool:

View file

@ -1,4 +1,3 @@
import asyncio
import logging
import re
from typing import TYPE_CHECKING
@ -146,9 +145,8 @@ class TwitchHandler(BaseHandler):
)
try:
await asyncio.gather(
*[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
)
for item in filtered:
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
except Exception as e:
LOG.exception(e)
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")

View file

@ -1,4 +1,3 @@
import asyncio
import logging
import re
from typing import TYPE_CHECKING
@ -153,9 +152,8 @@ class YoutubeHandler(BaseHandler):
)
try:
await asyncio.gather(
*[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
)
for item in filtered:
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
except Exception as e:
LOG.exception(e)
LOG.error(f"'{task.name}': Error while adding items from task feed. {e!s}")

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
# flake8: noqa: SIM115 S310 T201
# flake8: noqa: S310 T201
import argparse
import os

View file

@ -38,7 +38,7 @@ ROOT_PATH: Path = Path(__file__).parent.absolute()
class Main:
def __init__(self, is_native: bool = False):
self._config = Config.get_instance(is_native=is_native)
self._config: Config = Config.get_instance(is_native=is_native)
self._app = web.Application()
self._app.on_shutdown.append(self.on_shutdown)
self._background_worker = BackgroundWorker()
@ -98,7 +98,7 @@ class Main:
raise
async def on_shutdown(self, _: web.Application):
await EventBus.get_instance().emit(
EventBus.get_instance().emit(
Events.SHUTDOWN,
data={"app": self._app},
title="Application Shutdown",
@ -112,12 +112,15 @@ class Main:
host = host or self._config.host
port = port or self._config.port
EventBus.get_instance().sync_emit(
EventBus.get_instance().emit(
Events.STARTUP,
data={"app": self._app},
title="Application Startup",
message="The application is starting up.",
)
if self._config.debug:
EventBus.get_instance().debug_enable()
Scheduler.get_instance().attach(self._app)
self._socket.attach(self._app)
@ -131,7 +134,7 @@ class Main:
DLFields.get_instance().attach(self._app)
self._background_worker.attach(self._app)
EventBus.get_instance().sync_emit(
EventBus.get_instance().emit(
Events.LOADED,
data={"app": self._app},
title="Application Loaded",
@ -147,13 +150,11 @@ class Main:
loop = asyncio.get_event_loop()
EventBus.get_instance().sync_emit(
EventBus.get_instance().emit(
Events.STARTED,
data={"app": self._app},
title="Application Started",
message="The application has started successfully.",
loop=loop,
wait=False,
)
if loop and self._config.debug:

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
@ -113,7 +114,7 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -
status=web.HTTPInternalServerError.status_code,
)
await notify.emit(Events.CONDITIONS_UPDATE, data=items)
notify.emit(Events.CONDITIONS_UPDATE, data=items)
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)
@ -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

@ -96,5 +96,5 @@ async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) ->
status=web.HTTPInternalServerError.status_code,
)
await notify.emit(Events.DLFIELDS_UPDATE, data=items)
notify.emit(Events.DLFIELDS_UPDATE, data=items)
return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -169,7 +169,7 @@ async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder,
if updated:
queue.done.put(item)
await notify.emit(Events.ITEM_UPDATED, data=item.info)
notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data=item.info,
@ -309,7 +309,7 @@ async def item_archive_add(request: Request, queue: DownloadQueue, notify: Event
item.info.archive_status(force=True)
queue.done.put(item, no_notify=True)
await notify.emit(Events.ITEM_UPDATED, data=item.info)
notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data={"message": f"item '{item.info.title}' archived."},
@ -367,7 +367,7 @@ async def item_archive_delete(request: Request, queue: DownloadQueue, notify: Ev
item.info.archive_status(force=True)
queue.done.put(item, no_notify=True)
await notify.emit(Events.ITEM_UPDATED, data=item.info)
notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(
data={"message": f"item '{item.info.title}' removed from archive."},

View file

@ -104,6 +104,6 @@ async def notification_test(encoder: Encoder, notify: EventBus) -> Response:
Response: The response object.
"""
await notify.emit(Events.TEST, title="Test Notification", message="This is a test notification.")
notify.emit(Events.TEST, title="Test Notification", message="This is a test notification.")
return web.json_response(data={}, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -96,5 +96,5 @@ async def presets_add(request: Request, encoder: Encoder, notify: EventBus) -> R
status=web.HTTPInternalServerError.status_code,
)
await notify.emit(Events.PRESETS_UPDATE, data=presets)
notify.emit(Events.PRESETS_UPDATE, data=presets)
return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -40,7 +40,7 @@ async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventB
queue.pause()
msg = "Non-active downloads have been paused."
await notify.emit(
notify.emit(
Events.PAUSED,
data={"paused": True, "at": time.time()},
title="Downloads Paused",
@ -75,7 +75,7 @@ async def downloads_resume(queue: DownloadQueue, encoder: Encoder, notify: Event
queue.resume()
msg = "Resumed all downloads."
await notify.emit(
notify.emit(
Events.RESUMED,
data={"paused": False, "at": time.time()},
title="Downloads Resumed",
@ -109,7 +109,7 @@ async def shutdown_system(request: Request, config: Config, encoder: Encoder, no
app = request.app
async def do_shutdown():
await notify.emit(
notify.emit(
Events.SHUTDOWN,
data={"app": app},
title="Application Shutdown",

View file

@ -37,7 +37,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
depth_limit=config.download_path_depth-1,
)
await notify.emit(
notify.emit(
Events.CONNECTED,
data=data,
title="Client connected",
@ -78,7 +78,7 @@ async def subscribe(config: Config, notify: EventBus, sio: socketio.AsyncServer,
"""
if not isinstance(data, str) or not data:
await notify.emit(
notify.emit(
Events.LOG_ERROR,
title="Subscription Error",
message="Invalid event type was expecting a string.",

View file

@ -12,13 +12,13 @@ LOG: logging.Logger = logging.getLogger(__name__)
async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
data = data if isinstance(data, dict) else {}
if not (url := data.get("url", None)):
await notify.emit(Events.LOG_ERROR, title="Invalid request", message="No URL provided.", to=sid)
notify.emit(Events.LOG_ERROR, title="Invalid request", message="No URL provided.", to=sid)
return
item: Item = Item.format(data)
try:
status = await queue.add(item=item)
await notify.emit(
notify.emit(
event=Events.ITEM_STATUS,
title="Adding URL",
message=f"Adding URL '{url}' to the download queue.",
@ -27,7 +27,7 @@ async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
)
except ValueError as e:
LOG.exception(e)
await notify.emit(
notify.emit(
Events.LOG_ERROR, data={"preset": item.preset}, title="Error Adding URL", message=str(e), to=sid
)
@ -35,7 +35,7 @@ async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
@route(RouteType.SOCKET, "item_cancel", "item_cancel")
async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str):
if not (data := data if isinstance(data, str) else None):
await notify.emit(Events.LOG_ERROR, title="Invalid Request", message="No item ID provided.", to=sid)
notify.emit(Events.LOG_ERROR, title="Invalid Request", message="No item ID provided.", to=sid)
return
await queue.cancel([data])
@ -46,7 +46,7 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di
data = data if isinstance(data, dict) else {}
if not (id := data.get("id", None)):
await notify.emit(Events.LOG_ERROR, title="Invalid Request", message="No item ID provided.", to=sid)
notify.emit(Events.LOG_ERROR, title="Invalid Request", message="No item ID provided.", to=sid)
return
await queue.clear([id], remove_file=bool(data.get("remove_file", False)))
@ -55,7 +55,7 @@ async def item_delete(queue: DownloadQueue, notify: EventBus, sid: str, data: di
@route(RouteType.SOCKET, "item_start", "item_start")
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data:
await notify.emit(
notify.emit(
Events.LOG_ERROR,
title="Invalid Request",
message="No items provided to start.",
@ -72,7 +72,7 @@ async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: lis
@route(RouteType.SOCKET, "item_pause", "item_pause")
async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
if not data:
await notify.emit(
notify.emit(
Events.LOG_ERROR,
title="Invalid Request",
message="No items provided to pause.",

View file

@ -17,7 +17,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
@route(RouteType.SOCKET, "cli_post", "socket_cli_post")
async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
if not config.console_enabled:
await notify.emit(
notify.emit(
Events.LOG_ERROR,
title="Feature disabled",
message="Console feature is disabled.",
@ -26,7 +26,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
return
if not data:
await notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
notify.emit(Events.CLI_CLOSE, data={"exitcode": 0}, to=sid)
return
import asyncio
@ -93,7 +93,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.rstrip(b"\n")
await notify.emit(
notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
@ -112,7 +112,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
if not chunk:
if buffer:
await notify.emit(
notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
to=sid,
@ -123,7 +123,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
*lines, buffer = buffer.split(b"\n")
for line in lines:
await notify.emit(
notify.emit(
Events.CLI_OUTPUT,
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
to=sid,
@ -141,6 +141,6 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
except Exception as e:
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
LOG.exception(e)
await notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
notify.emit(Events.CLI_OUTPUT, data={"type": "stderr", "line": str(e)}, to=sid)
finally:
await notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)
notify.emit(Events.CLI_CLOSE, data={"exitcode": returncode}, to=sid)

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()

502
app/tests/test_ag_utils.py Normal file
View file

@ -0,0 +1,502 @@
"""
Tests for ag_utils.py functions.
This test suite provides comprehensive coverage for all functions in ag_utils.py:
- get_value: Tests callable detection and value retrieval
- ag_set: Tests nested dictionary path setting with various scenarios
- ag: Tests nested dictionary/list/object access with dot notation
- ag_sets: Tests bulk setting of multiple paths
- ag_exists: Tests existence checking for nested paths
- ag_delete: Tests deletion of nested paths and keys
Total test functions: 53
All edge cases, error conditions, and normal operations are covered.
"""
from unittest.mock import MagicMock
import pytest
from app.library.ag_utils import (
ag,
ag_delete,
ag_exists,
ag_set,
ag_sets,
get_value,
)
class TestGetValue:
"""Test the get_value function."""
def test_get_value_with_value(self):
"""Test get_value returns the value when not callable."""
assert get_value(42) == 42
assert get_value("test") == "test"
assert get_value([1, 2, 3]) == [1, 2, 3]
assert get_value({"key": "value"}) == {"key": "value"}
assert get_value(None) is None
def test_get_value_with_callable(self):
"""Test get_value calls the function when callable."""
mock_func = MagicMock(return_value="called")
result = get_value(mock_func)
assert result == "called"
mock_func.assert_called_once()
def test_get_value_with_lambda(self):
"""Test get_value with lambda functions."""
assert get_value(lambda: 100) == 100
assert get_value(lambda: "lambda_result") == "lambda_result"
def test_get_value_with_function(self):
"""Test get_value with regular functions."""
def test_func():
return "function_result"
assert get_value(test_func) == "function_result"
class TestAgSet:
"""Test the ag_set function."""
def test_ag_set_simple_path(self):
"""Test setting a value with simple path."""
data = {}
result = ag_set(data, "key", "value")
assert result == {"key": "value"}
assert data == {"key": "value"}
def test_ag_set_nested_path(self):
"""Test setting a value with nested path."""
data = {}
result = ag_set(data, "a.b.c", "nested_value")
expected = {"a": {"b": {"c": "nested_value"}}}
assert result == expected
assert data == expected
def test_ag_set_existing_structure(self):
"""Test setting a value in existing structure."""
data = {"a": {"b": {"existing": "value"}}}
ag_set(data, "a.b.c", "new_value")
expected = {"a": {"b": {"existing": "value", "c": "new_value"}}}
assert data == expected
def test_ag_set_overwrite_existing(self):
"""Test overwriting existing value."""
data = {"a": {"b": "old_value"}}
ag_set(data, "a.b", "new_value")
assert data == {"a": {"b": "new_value"}}
def test_ag_set_custom_separator(self):
"""Test using custom separator."""
data = {}
ag_set(data, "a/b/c", "value", separator="/")
assert data == {"a": {"b": {"c": "value"}}}
def test_ag_set_overwrite_non_dict_intermediate(self):
"""Test overwriting non-dict intermediate value with dict."""
data = {"a": "not_a_dict"}
ag_set(data, "a.b", "value")
# The function should overwrite "not_a_dict" with a dict containing the new path
expected = {"a": {"b": "value"}}
assert data == expected
def test_ag_set_error_on_non_dict_final(self):
"""Test error when final target is not a dict."""
data = "not_a_dict"
with pytest.raises(RuntimeError, match="Cannot set value at path 'key'"):
ag_set(data, "key", "value")
class TestAg:
"""Test the ag function."""
def test_ag_with_none_path(self):
"""Test ag returns whole structure when path is None."""
data = {"a": 1, "b": 2}
assert ag(data, None) == data
assert ag(data, "") == data
def test_ag_simple_dict_access(self):
"""Test simple dictionary key access."""
data = {"x": 10, "y": 20}
assert ag(data, "x") == 10
assert ag(data, "y") == 20
def test_ag_missing_key_with_default(self):
"""Test accessing missing key returns default."""
data = {"x": 10}
assert ag(data, "missing", default=0) == 0
assert ag(data, "missing", default="not_found") == "not_found"
def test_ag_nested_dict_access(self):
"""Test nested dictionary access with dot notation."""
data = {"a": {"b": {"c": 42}}}
assert ag(data, "a.b.c") == 42
def test_ag_nested_missing_key(self):
"""Test nested access with missing intermediate keys."""
data = {"a": {"b": 1}}
assert ag(data, "a.missing.c", default="default") == "default"
assert ag(data, "missing.b.c", default="default") == "default"
def test_ag_list_access_by_index(self):
"""Test accessing list elements by index."""
data = [10, 20, 30]
assert ag(data, 0) == 10
assert ag(data, 1) == 20
assert ag(data, 2) == 30
def test_ag_list_access_out_of_bounds(self):
"""Test list access with out of bounds index."""
data = [10, 20]
assert ag(data, 5, default="default") == "default"
assert ag(data, -3, default="default") == "default" # -3 is out of bounds for 2-element list
def test_ag_list_negative_indices(self):
"""Test list access with valid negative indices."""
data = [10, 20, 30]
assert ag(data, -1) == 30 # Last element
assert ag(data, -2) == 20 # Second to last
assert ag(data, -3) == 10 # First element
def test_ag_mixed_dict_list_access(self):
"""Test accessing nested structure with dicts and lists."""
data = {"items": [{"name": "item1"}, {"name": "item2"}]}
assert ag(data, "items.0.name") == "item1"
assert ag(data, "items.1.name") == "item2"
def test_ag_list_of_paths(self):
"""Test trying multiple paths and returning first found."""
data = {"a": 1, "b": 2}
assert ag(data, ["missing1", "missing2", "a"], default="default") == 1
assert ag(data, ["missing1", "b", "a"], default="default") == 2
assert ag(data, ["missing1", "missing2"], default="default") == "default"
def test_ag_custom_separator(self):
"""Test using custom separator."""
data = {"a": {"b": {"c": 100}}}
assert ag(data, "a/b/c", separator="/") == 100
def test_ag_with_none_values(self):
"""Test ag behavior with None values."""
data = {"a": None, "b": {"c": None}}
assert ag(data, "a", default="default") == "default"
assert ag(data, "b.c", default="default") == "default"
def test_ag_with_callable_default(self):
"""Test ag with callable default value."""
data = {}
mock_default = MagicMock(return_value="called_default")
result = ag(data, "missing", default=mock_default)
assert result == "called_default"
mock_default.assert_called_once()
def test_ag_with_object_attributes(self):
"""Test ag with object attributes using vars()."""
class TestObj:
def __init__(self):
self.attr1 = "value1"
self.attr2 = {"nested": "value2"}
obj = TestObj()
assert ag(obj, "attr1") == "value1"
assert ag(obj, "attr2.nested") == "value2"
def test_ag_with_non_dict_non_list_fallback(self):
"""Test ag fallback for non-dict, non-list objects without vars."""
class NoVarsObj:
__slots__ = ["value"]
def __init__(self):
self.value = "test"
obj = NoVarsObj()
assert ag(obj, "anything", default="fallback") == "fallback"
class TestAgSets:
"""Test the ag_sets function."""
def test_ag_sets_multiple_paths(self):
"""Test setting multiple paths at once."""
data = {}
path_values = {"a.b.c": "value1", "a.b.d": "value2", "x.y": "value3"}
result = ag_sets(data, path_values)
expected = {"a": {"b": {"c": "value1", "d": "value2"}}, "x": {"y": "value3"}}
assert result == expected
assert data == expected
def test_ag_sets_custom_separator(self):
"""Test ag_sets with custom separator."""
data = {}
path_values = {"a/b/c": "value1", "x/y": "value2"}
ag_sets(data, path_values, separator="/")
expected = {"a": {"b": {"c": "value1"}}, "x": {"y": "value2"}}
assert data == expected
def test_ag_sets_existing_structure(self):
"""Test ag_sets with existing data structure."""
data = {"a": {"existing": "value"}}
path_values = {"a.b.c": "new_value", "d": "another_value"}
ag_sets(data, path_values)
expected = {"a": {"existing": "value", "b": {"c": "new_value"}}, "d": "another_value"}
assert data == expected
def test_ag_sets_empty_dict(self):
"""Test ag_sets with empty path_values dict."""
data = {"existing": "data"}
original = data.copy()
ag_sets(data, {})
assert data == original
class TestAgExists:
"""Test the ag_exists function."""
def test_ag_exists_simple_dict_key(self):
"""Test checking existence of simple dict keys."""
data = {"a": "value", "b": None, "c": 0}
assert ag_exists(data, "a") is True
assert ag_exists(data, "b") is False # None values return False
assert ag_exists(data, "c") is True # 0 is not None
assert ag_exists(data, "missing") is False
def test_ag_exists_nested_path(self):
"""Test checking existence of nested paths."""
data = {"a": {"b": {"c": "value", "d": None}}}
assert ag_exists(data, "a.b.c") is True
assert ag_exists(data, "a.b.d") is False # None value
assert ag_exists(data, "a.b.missing") is False
assert ag_exists(data, "a.missing.c") is False
def test_ag_exists_list_indices(self):
"""Test checking existence of list indices."""
data = [10, None, 30]
assert ag_exists(data, 0) is True
assert ag_exists(data, 1) is False # None value
assert ag_exists(data, 2) is True
assert ag_exists(data, 5) is False # Out of bounds
def test_ag_exists_mixed_structure(self):
"""Test checking existence in mixed dict/list structure."""
data = {"items": [{"name": "item1"}, None, {"name": "item3"}]}
assert ag_exists(data, "items.0.name") is True
assert ag_exists(data, "items.1.name") is False # items[1] is None
assert ag_exists(data, "items.2.name") is True
assert ag_exists(data, "items.5.name") is False # Out of bounds
def test_ag_exists_custom_separator(self):
"""Test ag_exists with custom separator."""
data = {"a": {"b": {"c": "value"}}}
assert ag_exists(data, "a/b/c", separator="/") is True
assert ag_exists(data, "a/b/missing", separator="/") is False
def test_ag_exists_with_object(self):
"""Test ag_exists with object using vars()."""
class TestObj:
def __init__(self):
self.attr = "value"
self.nested = {"key": "value"}
obj = TestObj()
assert ag_exists(obj, "attr") is True
assert ag_exists(obj, "nested.key") is True
assert ag_exists(obj, "missing") is False
def test_ag_exists_with_non_vars_object(self):
"""Test ag_exists with object that doesn't support vars()."""
class NoVarsObj:
__slots__ = []
obj = NoVarsObj()
assert ag_exists(obj, "anything") is False
class TestAgDelete:
"""Test the ag_delete function."""
def test_ag_delete_simple_dict_key(self):
"""Test deleting simple dictionary keys."""
data = {"a": 1, "b": 2, "c": 3}
result = ag_delete(data, "b")
assert result == {"a": 1, "c": 3}
assert data == {"a": 1, "c": 3}
def test_ag_delete_nested_path(self):
"""Test deleting nested dictionary paths."""
data = {"a": {"b": {"c": 1, "d": 2}, "e": 3}}
ag_delete(data, "a.b.c")
expected = {"a": {"b": {"d": 2}, "e": 3}}
assert data == expected
def test_ag_delete_list_index(self):
"""Test deleting list elements by index."""
data = [10, 20, 30, 40]
ag_delete(data, 1)
assert data == [10, 30, 40]
def test_ag_delete_mixed_structure(self):
"""Test deleting from mixed dict/list structure."""
data = {"items": [{"name": "item1"}, {"name": "item2", "value": 100}]}
ag_delete(data, "items.1.value")
expected = {"items": [{"name": "item1"}, {"name": "item2"}]}
assert data == expected
def test_ag_delete_multiple_paths(self):
"""Test deleting multiple paths at once."""
data = {"a": {"b": 1, "c": 2}, "d": 3, "e": 4}
ag_delete(data, ["a.b", "d"])
expected = {"a": {"c": 2}, "e": 4}
assert data == expected
def test_ag_delete_custom_separator(self):
"""Test ag_delete with custom separator."""
data = {"a": {"b": {"c": 1}}}
ag_delete(data, "a/b/c", separator="/")
assert data == {"a": {"b": {}}}
def test_ag_delete_missing_key(self):
"""Test deleting non-existent keys (should not raise error)."""
data = {"a": {"b": 1}}
original = {"a": {"b": 1}}
# Should not raise error and not modify data
ag_delete(data, "missing")
ag_delete(data, "a.missing")
ag_delete(data, "a.b.c")
assert data == original
def test_ag_delete_out_of_bounds_list(self):
"""Test deleting out of bounds list index (should not raise error)."""
data = [1, 2, 3]
original = [1, 2, 3]
ag_delete(data, 10) # Out of bounds
ag_delete(data, -1) # Negative index
assert data == original
def test_ag_delete_with_object(self):
"""Test ag_delete with object using vars()."""
class TestObj:
def __init__(self):
self.attr1 = "value1"
self.attr2 = {"nested": "value2"}
obj = TestObj()
ag_delete(obj, "attr1")
# Check that attr1 was deleted
assert not hasattr(obj, "attr1")
assert hasattr(obj, "attr2")
def test_ag_delete_invalid_list_string_index(self):
"""Test ag_delete with invalid string index for list."""
data = {"items": [1, 2, 3]}
original_items = [1, 2, 3]
ag_delete(data, "items.invalid_index")
# Should not modify the list
assert data["items"] == original_items
def test_ag_delete_path_through_none(self):
"""Test ag_delete when path goes through None value."""
data = {"a": {"b": None}}
original = {"a": {"b": None}}
ag_delete(data, "a.b.c") # Can't traverse through None
assert data == original
class TestEdgeCases:
"""Test edge cases and error conditions."""
def test_empty_data_structures(self):
"""Test functions with empty data structures."""
empty_dict = {}
empty_list = []
# ag function
assert ag(empty_dict, "key", default="default") == "default"
assert ag(empty_list, 0, default="default") == "default"
# ag_exists function
assert ag_exists(empty_dict, "key") is False
assert ag_exists(empty_list, 0) is False
# ag_delete function (should not raise errors)
ag_delete(empty_dict, "key")
ag_delete(empty_list, 0)
assert empty_dict == {}
assert empty_list == []
def test_deeply_nested_structures(self):
"""Test with deeply nested structures."""
# Create 10-level deep structure
data = {}
current = data
for i in range(10):
current[f"level{i}"] = {}
current = current[f"level{i}"]
current["value"] = "deep_value"
path = ".".join(f"level{i}" for i in range(10)) + ".value"
# Test ag function
assert ag(data, path) == "deep_value"
# Test ag_exists function
assert ag_exists(data, path) is True
# Test ag_set function
ag_set(data, path.replace(".value", ".new_value"), "new_deep_value")
new_path = ".".join(f"level{i}" for i in range(10)) + ".new_value"
assert ag(data, new_path) == "new_deep_value"
def test_special_characters_in_keys(self):
"""Test with special characters in dictionary keys."""
data = {"key with spaces": "value1", "key.with.dots": "value2", "key/with/slashes": "value3"}
# These should work with direct key access
assert ag(data, "key with spaces") == "value1"
assert ag(data, "key.with.dots") == "value2"
assert ag(data, "key/with/slashes") == "value3"
# Test existence
assert ag_exists(data, "key with spaces") is True
assert ag_exists(data, "key.with.dots") is True
# Test deletion
ag_delete(data, "key with spaces")
assert "key with spaces" not in data
def test_type_consistency(self):
"""Test type consistency across operations."""
data = {"string": "test", "number": 42, "boolean": True, "list": [1, 2, 3], "dict": {"nested": "value"}}
# All values should be retrieved correctly
assert ag(data, "string") == "test"
assert ag(data, "number") == 42
assert ag(data, "boolean") is True
assert ag(data, "list") == [1, 2, 3]
assert ag(data, "dict") == {"nested": "value"}
# All should exist
for key in data:
assert ag_exists(data, key) is True

260
app/tests/test_cache.py Normal file
View file

@ -0,0 +1,260 @@
"""
Tests for cache.py - Thread-safe caching utilities.
This test suite provides comprehensive coverage for the Cache class:
- Tests basic cache operations (set, get, delete, clear)
- Tests TTL (time-to-live) functionality
- Tests thread safety
- Tests cache expiration
- Tests default value handling
- Tests key existence checking
- Tests hash generation
- Tests async methods
Total test functions: 15
All cache operations and edge cases are covered.
"""
import asyncio
import threading
import time
import pytest
from app.library.cache import Cache
class TestCache:
"""Test the Cache class."""
def setup_method(self):
"""Set up test fixtures."""
# Clear any existing cache instance
if hasattr(Cache, "_instances"):
Cache._instances.clear()
self.cache = Cache()
def test_singleton_behavior(self):
"""Test that Cache follows singleton pattern."""
cache1 = Cache()
cache2 = Cache()
assert cache1 is cache2
def test_basic_set_and_get(self):
"""Test basic cache set and get operations."""
self.cache.set("key1", "value1")
assert self.cache.get("key1") == "value1"
def test_get_with_default(self):
"""Test get with default value for non-existent keys."""
assert self.cache.get("nonexistent", "default") == "default"
assert self.cache.get("nonexistent") is None
def test_set_with_ttl(self):
"""Test setting values with TTL."""
self.cache.set("temp_key", "temp_value", ttl=0.1)
assert self.cache.get("temp_key") == "temp_value"
# Wait for expiration
time.sleep(0.2)
assert self.cache.get("temp_key") is None
def test_set_without_ttl(self):
"""Test setting values without TTL (permanent)."""
self.cache.set("permanent_key", "permanent_value")
assert self.cache.get("permanent_key") == "permanent_value"
# Should still be there after some time
time.sleep(0.1)
assert self.cache.get("permanent_key") == "permanent_value"
def test_has_key(self):
"""Test key existence checking."""
assert not self.cache.has("nonexistent")
self.cache.set("existing", "value")
assert self.cache.has("existing")
def test_has_key_with_expiration(self):
"""Test key existence with expired keys."""
self.cache.set("expiring", "value", ttl=0.1)
assert self.cache.has("expiring")
time.sleep(0.2)
assert not self.cache.has("expiring")
def test_ttl_method(self):
"""Test TTL retrieval."""
# Key without TTL
self.cache.set("permanent", "value")
assert self.cache.ttl("permanent") is None
# Key with TTL
self.cache.set("temporary", "value", ttl=1.0)
ttl = self.cache.ttl("temporary")
assert ttl is not None
assert 0.5 < ttl <= 1.0
# Non-existent key
assert self.cache.ttl("nonexistent") is None
def test_delete_key(self):
"""Test key deletion."""
self.cache.set("to_delete", "value")
assert self.cache.get("to_delete") == "value"
self.cache.delete("to_delete")
assert self.cache.get("to_delete") is None
def test_delete_nonexistent_key(self):
"""Test deleting non-existent key (should not raise error)."""
self.cache.delete("nonexistent") # Should not raise
def test_clear_cache(self):
"""Test clearing all cache entries."""
self.cache.set("key1", "value1")
self.cache.set("key2", "value2")
self.cache.clear()
assert self.cache.get("key1") is None
assert self.cache.get("key2") is None
def test_hash_method(self):
"""Test hash generation."""
hash1 = self.cache.hash("test_string")
hash2 = self.cache.hash("test_string")
hash3 = self.cache.hash("different_string")
# Same input should produce same hash
assert hash1 == hash2
# Different input should produce different hash
assert hash1 != hash3
# Should be valid SHA-256 hex string
assert len(hash1) == 64
assert all(c in "0123456789abcdef" for c in hash1)
def test_thread_safety(self):
"""Test thread safety of cache operations."""
results = []
errors = []
def worker(worker_id):
try:
for i in range(10):
key = f"worker_{worker_id}_key_{i}"
value = f"worker_{worker_id}_value_{i}"
self.cache.set(key, value)
retrieved = self.cache.get(key)
if retrieved == value:
results.append(f"{worker_id}_{i}_success")
else:
errors.append(f"{worker_id}_{i}_mismatch: {retrieved} != {value}")
except Exception as e:
errors.append(f"Worker {worker_id} error: {e}")
# Create multiple threads
threads = []
for i in range(5):
thread = threading.Thread(target=worker, args=(i,))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Check results
assert len(errors) == 0, f"Thread safety errors: {errors}"
assert len(results) == 50 # 5 workers * 10 operations each
def test_async_set(self):
"""Test async set method using asyncio.run."""
async def async_test():
await self.cache.aset("async_key", "async_value")
assert self.cache.get("async_key") == "async_value"
asyncio.run(async_test())
def test_async_set_with_ttl(self):
"""Test async set with TTL using asyncio.run."""
async def async_test():
await self.cache.aset("async_temp", "async_value", ttl=0.1)
assert self.cache.get("async_temp") == "async_value"
await asyncio.sleep(0.2)
assert self.cache.get("async_temp") is None
asyncio.run(async_test())
asyncio.run(async_test())
def test_expired_key_cleanup_on_get(self):
"""Test that expired keys are cleaned up when accessed."""
# Set a key with very short TTL
self.cache.set("cleanup_test", "value", ttl=0.05)
# Verify it's initially there
assert self.cache.get("cleanup_test") == "value"
# Wait for expiration
time.sleep(0.1)
# Getting expired key should clean it up and return None
assert self.cache.get("cleanup_test") is None
# Key should be removed from internal cache
assert "cleanup_test" not in self.cache._cache
def test_expired_key_cleanup_on_has(self):
"""Test that expired keys are cleaned up when checking existence."""
# Set a key with very short TTL
self.cache.set("has_cleanup", "value", ttl=0.05)
# Verify it's initially there
assert self.cache.has("has_cleanup")
# Wait for expiration
time.sleep(0.1)
# Checking existence of expired key should clean it up
assert not self.cache.has("has_cleanup")
# Key should be removed from internal cache
assert "has_cleanup" not in self.cache._cache
def test_complex_data_types(self):
"""Test caching of complex data types."""
# Test list
test_list = [1, 2, {"nested": "dict"}]
self.cache.set("list_key", test_list)
assert self.cache.get("list_key") == test_list
# Test dict
test_dict = {"key": "value", "nested": {"list": [1, 2, 3]}}
self.cache.set("dict_key", test_dict)
assert self.cache.get("dict_key") == test_dict
# Test custom object
class CustomObject:
def __init__(self, value):
self.value = value
def __eq__(self, other):
return isinstance(other, CustomObject) and self.value == other.value
def __hash__(self):
return hash(self.value)
custom_obj = CustomObject("test_value")
self.cache.set("object_key", custom_obj)
retrieved = self.cache.get("object_key")
assert retrieved == custom_obj
if __name__ == "__main__":
pytest.main([__file__])

View file

@ -0,0 +1,751 @@
"""
Tests for conditions.py - Download conditions management.
This test suite provides comprehensive coverage for the conditions module:
- Tests Condition dataclass functionality
- Tests Conditions singleton class behavior
- Tests condition loading, saving, and validation
- Tests condition matching against info dicts
- Tests error handling and edge cases
Total test functions: 15+
All condition management functionality and edge cases are covered.
"""
import json
import tempfile
import uuid
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.library.conditions import Condition, Conditions
from app.library.Singleton import Singleton
class TestCondition:
"""Test the Condition dataclass."""
def test_condition_creation_with_defaults(self):
"""Test creating a condition with default values."""
condition = Condition(name="test", filter="duration > 60")
# Check that ID is generated
assert condition.id
assert isinstance(condition.id, str)
# Check required fields
assert condition.name == "test"
assert condition.filter == "duration > 60"
# Check defaults
assert condition.cli == ""
assert condition.extras == {}
def test_condition_creation_with_all_fields(self):
"""Test creating a condition with all fields specified."""
test_id = str(uuid.uuid4())
extras = {"key": "value", "number": 42}
condition = Condition(
id=test_id,
name="full_test",
filter="uploader = 'test'",
cli="--format best",
extras=extras
)
assert condition.id == test_id
assert condition.name == "full_test"
assert condition.filter == "uploader = 'test'"
assert condition.cli == "--format best"
assert condition.extras == extras
def test_condition_serialize(self):
"""Test condition serialization to dict."""
condition = Condition(
name="serialize_test",
filter="title ~= 'test'",
cli="--audio-quality 0",
extras={"tag": "music"}
)
serialized = condition.serialize()
assert isinstance(serialized, dict)
assert serialized["name"] == "serialize_test"
assert serialized["filter"] == "title ~= 'test'"
assert serialized["cli"] == "--audio-quality 0"
assert serialized["extras"] == {"tag": "music"}
assert "id" in serialized
def test_condition_json(self):
"""Test condition JSON serialization."""
condition = Condition(name="json_test", filter="duration < 300")
json_str = condition.json()
assert isinstance(json_str, str)
# Should be valid JSON
data = json.loads(json_str)
assert data["name"] == "json_test"
assert data["filter"] == "duration < 300"
def test_condition_get_method(self):
"""Test condition get method for accessing fields."""
condition = Condition(
name="get_test",
filter="view_count > 1000",
extras={"category": "popular"}
)
assert condition.get("name") == "get_test"
assert condition.get("filter") == "view_count > 1000"
assert condition.get("extras") == {"category": "popular"}
assert condition.get("nonexistent") is None
assert condition.get("nonexistent", "default") == "default"
class TestConditions:
"""Test the Conditions singleton class."""
def setup_method(self):
"""Set up test fixtures by clearing singleton instances."""
# Clear singleton instances before each test
Singleton._instances.clear()
# Reset class variable
Conditions._items = []
Conditions._instance = None
def test_conditions_singleton(self):
"""Test that Conditions follows singleton pattern."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "test_conditions.json"
instance1 = Conditions(file=file_path)
instance2 = Conditions.get_instance()
assert instance1 is instance2
def test_conditions_initialization(self):
"""Test Conditions initialization with file path."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "test_conditions.json"
conditions = Conditions(file=file_path)
assert conditions._file == file_path
assert isinstance(conditions._items, list)
@patch("app.library.conditions.Config.get_instance")
def test_conditions_default_file_path(self, mock_config):
"""Test Conditions uses default config path when no file specified."""
mock_config_instance = MagicMock()
mock_config_instance.config_path = "/test/config"
mock_config.return_value = mock_config_instance
conditions = Conditions()
expected_path = Path("/test/config") / "conditions.json"
assert conditions._file == expected_path
def test_get_all_empty(self):
"""Test get_all returns empty list when no conditions loaded."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "empty_conditions.json"
conditions = Conditions(file=file_path)
result = conditions.get_all()
assert isinstance(result, list)
assert len(result) == 0
def test_clear(self):
"""Test clearing all conditions."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "clear_test.json"
conditions = Conditions(file=file_path)
# Add some test conditions
conditions._items = [
Condition(name="test1", filter="duration > 60"),
Condition(name="test2", filter="uploader = 'test'")
]
result = conditions.clear()
assert result is conditions # Should return self
assert len(conditions._items) == 0
def test_clear_when_already_empty(self):
"""Test clearing when conditions list is already empty."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "already_empty.json"
conditions = Conditions(file=file_path)
result = conditions.clear()
assert result is conditions
assert len(conditions._items) == 0
def test_load_nonexistent_file(self):
"""Test loading from non-existent file."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "nonexistent.json"
conditions = Conditions(file=file_path)
result = conditions.load()
assert result is conditions
assert len(conditions._items) == 0
def test_load_empty_file(self):
"""Test loading from empty file."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "empty.json"
file_path.touch() # Create empty file
conditions = Conditions(file=file_path)
result = conditions.load()
assert result is conditions
assert len(conditions._items) == 0
def test_load_valid_conditions(self):
"""Test loading valid conditions from file."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "valid_conditions.json"
# Create test data
test_data = [
{
"id": str(uuid.uuid4()),
"name": "short_videos",
"filter": "duration < 300",
"cli": "--format worst",
"extras": {"category": "short"}
},
{
"id": str(uuid.uuid4()),
"name": "music_videos",
"filter": "title ~= 'music'",
"cli": "--audio-quality 0",
"extras": {"type": "audio"}
}
]
file_path.write_text(json.dumps(test_data, indent=4))
conditions = Conditions(file=file_path)
result = conditions.load()
assert result is conditions
assert len(conditions._items) == 2
# Check first condition
assert conditions._items[0].name == "short_videos"
assert conditions._items[0].filter == "duration < 300"
assert conditions._items[0].cli == "--format worst"
assert conditions._items[0].extras == {"category": "short"}
# Check second condition
assert conditions._items[1].name == "music_videos"
assert conditions._items[1].filter == "title ~= 'music'"
def test_load_conditions_without_id(self):
"""Test loading conditions that don't have ID (should generate ID)."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_id_conditions.json"
# Create test data without ID
test_data = [
{
"name": "no_id_test",
"filter": "duration > 120"
}
]
file_path.write_text(json.dumps(test_data))
with patch.object(Conditions, "save") as mock_save:
conditions = Conditions(file=file_path)
conditions.load()
# Should have generated ID
assert len(conditions._items) == 1
assert conditions._items[0].id
assert conditions._items[0].name == "no_id_test"
# Should call save due to changes
mock_save.assert_called_once()
def test_load_conditions_without_extras(self):
"""Test loading conditions that don't have extras field."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_extras_conditions.json"
test_data = [
{
"id": str(uuid.uuid4()),
"name": "no_extras_test",
"filter": "uploader = 'test'"
}
]
file_path.write_text(json.dumps(test_data))
with patch.object(Conditions, "save") as mock_save:
conditions = Conditions(file=file_path)
conditions.load()
# Should have generated empty extras
assert len(conditions._items) == 1
assert conditions._items[0].extras == {}
# Should call save due to changes
mock_save.assert_called_once()
def test_load_invalid_json(self):
"""Test loading file with invalid JSON."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid.json"
file_path.write_text("invalid json content")
conditions = Conditions(file=file_path)
result = conditions.load()
assert result is conditions
assert len(conditions._items) == 0
def test_load_invalid_condition_data(self):
"""Test loading file with invalid condition data."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_data.json"
# Missing required fields
test_data = [
{"id": "valid", "name": "valid", "filter": "duration > 60"},
{"invalid": "data"} # Missing required fields
]
file_path.write_text(json.dumps(test_data))
conditions = Conditions(file=file_path)
result = conditions.load()
# Should load only valid conditions
assert result is conditions
assert len(conditions._items) == 1
assert conditions._items[0].name == "valid"
def test_validate_valid_condition_dict(self):
"""Test validating a valid condition dictionary."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "validate_test.json"
conditions = Conditions(file=file_path)
valid_condition = {
"id": str(uuid.uuid4()),
"name": "valid_test",
"filter": "duration > 60",
"cli": "--format best",
"extras": {"key": "value"}
}
result = conditions.validate(valid_condition)
assert result is True
def test_validate_valid_condition_object(self):
"""Test validating a valid Condition object."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "validate_obj_test.json"
conditions = Conditions(file=file_path)
valid_condition = Condition(
name="valid_obj_test",
filter="uploader = 'test'"
)
result = conditions.validate(valid_condition)
assert result is True
def test_validate_missing_id(self):
"""Test validating condition without ID."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_id_validate.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"name": "no_id_test",
"filter": "duration > 60"
}
with pytest.raises(ValueError, match="No id found"):
conditions.validate(invalid_condition)
def test_validate_missing_name(self):
"""Test validating condition without name."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_name_validate.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"id": str(uuid.uuid4()),
"filter": "duration > 60"
}
with pytest.raises(ValueError, match="No name found"):
conditions.validate(invalid_condition)
def test_validate_missing_filter(self):
"""Test validating condition without filter."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_filter_validate.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"id": str(uuid.uuid4()),
"name": "no_filter_test"
}
with pytest.raises(ValueError, match="No filter found"):
conditions.validate(invalid_condition)
def test_validate_invalid_filter(self):
"""Test validating condition with invalid filter syntax."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_filter.json"
conditions = Conditions(file=file_path)
# Use a filter that will cause a syntax error in the parser
invalid_condition = {
"id": str(uuid.uuid4()),
"name": "invalid_filter_test",
"filter": "duration > & < 60", # Invalid syntax with consecutive operators
"cli": "",
"extras": {}
}
with pytest.raises(ValueError, match="Invalid filter"):
conditions.validate(invalid_condition)
def test_validate_invalid_cli(self):
"""Test validating condition with invalid CLI options."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_cli.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"id": str(uuid.uuid4()),
"name": "invalid_cli_test",
"filter": "duration > 60",
"cli": "--invalid-option-that-does-not-exist"
}
with pytest.raises(ValueError, match="Invalid command options"):
conditions.validate(invalid_condition)
def test_validate_invalid_extras_type(self):
"""Test validating condition with non-dict extras."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_extras.json"
conditions = Conditions(file=file_path)
invalid_condition = {
"id": str(uuid.uuid4()),
"name": "invalid_extras_test",
"filter": "duration > 60",
"extras": "not a dict"
}
with pytest.raises(ValueError, match="Extras must be a dictionary"):
conditions.validate(invalid_condition)
def test_validate_invalid_item_type(self):
"""Test validating invalid item type."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_type.json"
conditions = Conditions(file=file_path)
with pytest.raises(ValueError, match=r"Unexpected.*item type"):
conditions.validate("invalid type")
def test_save_conditions(self):
"""Test saving conditions to file."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "save_test.json"
conditions = Conditions(file=file_path)
test_conditions = [
Condition(name="save_test1", filter="duration > 60"),
Condition(name="save_test2", filter="uploader = 'test'")
]
result = conditions.save(test_conditions)
assert result is conditions
assert file_path.exists()
# Verify file content
saved_data = json.loads(file_path.read_text())
assert len(saved_data) == 2
assert saved_data[0]["name"] == "save_test1"
assert saved_data[1]["name"] == "save_test2"
def test_save_conditions_dict_format(self):
"""Test saving conditions in dictionary format."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "save_dict_test.json"
conditions = Conditions(file=file_path)
test_conditions = [
{
"id": str(uuid.uuid4()),
"name": "dict_test",
"filter": "duration < 300",
"cli": "",
"extras": {}
}
]
conditions.save(test_conditions)
assert file_path.exists()
saved_data = json.loads(file_path.read_text())
assert len(saved_data) == 1
assert saved_data[0]["name"] == "dict_test"
def test_has_condition_by_id(self):
"""Test checking if condition exists by ID."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "has_test.json"
conditions = Conditions(file=file_path)
test_id = str(uuid.uuid4())
test_condition = Condition(id=test_id, name="has_test", filter="duration > 60")
conditions._items = [test_condition]
assert conditions.has(test_id) is True
assert conditions.has("nonexistent") is False
def test_has_condition_by_name(self):
"""Test checking if condition exists by name."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "has_name_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="has_name_test", filter="uploader = 'test'")
conditions._items = [test_condition]
assert conditions.has("has_name_test") is True
assert conditions.has("nonexistent_name") is False
def test_get_condition_by_id(self):
"""Test getting condition by ID."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "get_id_test.json"
conditions = Conditions(file=file_path)
test_id = str(uuid.uuid4())
test_condition = Condition(id=test_id, name="get_id_test", filter="duration > 120")
conditions._items = [test_condition]
result = conditions.get(test_id)
assert result is test_condition
assert conditions.get("nonexistent") is None
def test_get_condition_by_name(self):
"""Test getting condition by name."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "get_name_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="get_name_test", filter="title ~= 'music'")
conditions._items = [test_condition]
result = conditions.get("get_name_test")
assert result is test_condition
def test_get_condition_empty_id_or_name(self):
"""Test getting condition with empty ID or name."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "empty_get_test.json"
conditions = Conditions(file=file_path)
assert conditions.get("") is None
assert conditions.get(None) is None
@patch("app.library.conditions.match_str")
def test_match_condition_found(self, mock_match_str):
"""Test matching condition against info dict."""
mock_match_str.return_value = True
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "match_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="match_test", filter="duration > 60")
conditions._items = [test_condition]
info_dict = {"duration": 120, "title": "Test Video"}
result = conditions.match(info_dict)
assert result is test_condition
mock_match_str.assert_called_once_with("duration > 60", info_dict)
@patch("app.library.conditions.match_str")
def test_match_condition_not_found(self, mock_match_str):
"""Test matching when no condition matches."""
mock_match_str.return_value = False
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_match_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="no_match_test", filter="duration > 300")
conditions._items = [test_condition]
info_dict = {"duration": 60, "title": "Short Video"}
result = conditions.match(info_dict)
assert result is None
def test_match_empty_conditions(self):
"""Test matching with empty conditions list."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "empty_match_test.json"
conditions = Conditions(file=file_path)
info_dict = {"duration": 120}
result = conditions.match(info_dict)
assert result is None
def test_match_invalid_info_dict(self):
"""Test matching with invalid info dict."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_info_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="test", filter="duration > 60")
conditions._items = [test_condition]
# Test with None
assert conditions.match(None) is None
# Test with empty dict
assert conditions.match({}) is None
# Test with non-dict
assert conditions.match("not a dict") is None
@patch("app.library.conditions.match_str")
def test_match_filter_evaluation_error(self, mock_match_str):
"""Test matching when filter evaluation raises exception."""
mock_match_str.side_effect = Exception("Filter error")
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "filter_error_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="error_test", filter="invalid filter")
conditions._items = [test_condition]
info_dict = {"duration": 120}
result = conditions.match(info_dict)
assert result is None
def test_match_empty_filter(self):
"""Test matching with condition that has empty filter."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "empty_filter_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="empty_filter", filter="")
conditions._items = [test_condition]
info_dict = {"duration": 120}
result = conditions.match(info_dict)
assert result is None
@patch("app.library.conditions.match_str")
def test_single_match_found(self, mock_match_str):
"""Test single condition matching."""
mock_match_str.return_value = True
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "single_match_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="single_test", filter="uploader = 'test'")
conditions._items = [test_condition]
info_dict = {"uploader": "test", "title": "Test Video"}
result = conditions.single_match("single_test", info_dict)
assert result is test_condition
mock_match_str.assert_called_once_with("uploader = 'test'", info_dict)
@patch("app.library.conditions.match_str")
def test_single_match_not_found(self, mock_match_str):
"""Test single condition matching when condition doesn't match."""
mock_match_str.return_value = False
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "single_no_match_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="single_no_match", filter="uploader = 'other'")
conditions._items = [test_condition]
info_dict = {"uploader": "test", "title": "Test Video"}
result = conditions.single_match("single_no_match", info_dict)
assert result is None
def test_single_match_nonexistent_condition(self):
"""Test single matching with non-existent condition name."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "nonexistent_single_test.json"
conditions = Conditions(file=file_path)
info_dict = {"duration": 120}
result = conditions.single_match("nonexistent", info_dict)
assert result is None
def test_single_match_condition_no_filter(self):
"""Test single matching with condition that has no filter."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_filter_single_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="no_filter_single", filter="")
conditions._items = [test_condition]
info_dict = {"duration": 120}
result = conditions.single_match("no_filter_single", info_dict)
assert result is None
def test_single_match_invalid_inputs(self):
"""Test single matching with invalid inputs."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "invalid_single_test.json"
conditions = Conditions(file=file_path)
# Test with empty conditions
assert conditions.single_match("test", {"duration": 120}) is None
# Test with None info
assert conditions.single_match("test", None) is None
# Test with empty info dict
assert conditions.single_match("test", {}) is None
# Test with non-dict info
assert conditions.single_match("test", "not a dict") is None

635
app/tests/test_dl_fields.py Normal file
View file

@ -0,0 +1,635 @@
"""
Tests for dl_fields.py - Download fields management.
This test suite provides comprehensive coverage for the dl_fields module:
- Tests FieldType enum functionality
- Tests DLField dataclass functionality
- Tests DLFields singleton class behavior
- Tests field loading, saving, and validation
- Tests field CRUD operations
- Tests error handling and edge cases
Total test functions: 25+
All dl_fields management functionality and edge cases are covered.
"""
import json
import tempfile
import uuid
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.library.dl_fields import DLField, DLFields, FieldType
from app.library.Singleton import Singleton
class TestFieldType:
"""Test the FieldType enum."""
def test_field_type_values(self):
"""Test FieldType enum values."""
assert FieldType.STRING == "string"
assert FieldType.TEXT == "text"
assert FieldType.BOOL == "bool"
def test_field_type_all(self):
"""Test FieldType.all() method."""
all_types = FieldType.all()
expected = ["string", "text", "bool"]
assert all_types == expected
assert len(all_types) == 3
def test_field_type_from_value_valid(self):
"""Test FieldType.from_value() with valid values."""
assert FieldType.from_value("string") == FieldType.STRING
assert FieldType.from_value("text") == FieldType.TEXT
assert FieldType.from_value("bool") == FieldType.BOOL
def test_field_type_from_value_invalid(self):
"""Test FieldType.from_value() with invalid values."""
with pytest.raises(ValueError, match="Invalid StoreType value"):
FieldType.from_value("invalid")
with pytest.raises(ValueError, match="Invalid StoreType value"):
FieldType.from_value("number")
with pytest.raises(ValueError, match="Invalid StoreType value"):
FieldType.from_value("")
def test_field_type_str(self):
"""Test FieldType string conversion."""
assert str(FieldType.STRING) == "string"
assert str(FieldType.TEXT) == "text"
assert str(FieldType.BOOL) == "bool"
class TestDLField:
"""Test the DLField dataclass."""
def test_dl_field_creation_with_defaults(self):
"""Test creating a DLField with default values."""
field = DLField(name="test_field", description="Test description", field="--test-option")
# Check that ID is generated
assert field.id
assert isinstance(field.id, str)
# Check required fields
assert field.name == "test_field"
assert field.description == "Test description"
assert field.field == "--test-option"
# Check defaults
assert field.kind == FieldType.TEXT
assert field.icon == ""
assert field.order == 0
assert field.value == ""
assert field.extras == {}
def test_dl_field_creation_with_all_fields(self):
"""Test creating a DLField with all fields specified."""
test_id = str(uuid.uuid4())
extras = {"key": "value", "number": 42}
field = DLField(
id=test_id,
name="custom_field",
description="Custom description",
field="--custom-option",
kind=FieldType.BOOL,
icon="fa-check",
order=5,
value="default_value",
extras=extras,
)
assert field.id == test_id
assert field.name == "custom_field"
assert field.description == "Custom description"
assert field.field == "--custom-option"
assert field.kind == FieldType.BOOL
assert field.icon == "fa-check"
assert field.order == 5
assert field.value == "default_value"
assert field.extras == extras
def test_dl_field_serialize(self):
"""Test DLField serialization."""
extras = {"key": "value", "none_value": None}
field = DLField(name="test", description="Test field", field="--test", kind=FieldType.STRING, extras=extras)
serialized = field.serialize()
assert serialized["name"] == "test"
assert serialized["description"] == "Test field"
assert serialized["field"] == "--test"
assert serialized["kind"] == "string"
assert serialized["extras"] == {"key": "value"} # None values filtered out
def test_dl_field_json(self):
"""Test DLField JSON encoding."""
field = DLField(name="json_test", description="JSON test field", field="--json-test")
json_str = field.json()
assert isinstance(json_str, str)
# Should be valid JSON
parsed = json.loads(json_str)
assert parsed["name"] == "json_test"
assert parsed["description"] == "JSON test field"
assert parsed["field"] == "--json-test"
def test_dl_field_get_method(self):
"""Test DLField get method."""
field = DLField(name="get_test", description="Get test field", field="--get-test", order=3)
assert field.get("name") == "get_test"
assert field.get("order") == 3
assert field.get("nonexistent") is None
assert field.get("nonexistent", "default") == "default"
class TestDLFields:
"""Test the DLFields class."""
def setup_method(self):
"""Set up test fixtures."""
# Clear singleton instances before each test
Singleton._instances.clear()
if hasattr(DLFields, "_instances"):
DLFields._instances.clear()
# Also clear the class-level _items list
DLFields._items = []
@pytest.fixture
def temp_file(self):
"""Create a temporary file for testing."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
temp_path = Path(f.name)
yield temp_path
if temp_path.exists():
temp_path.unlink()
@pytest.fixture
def sample_fields_data(self):
"""Sample field data for testing."""
return [
{
"id": str(uuid.uuid4()),
"name": "quality",
"description": "Video quality setting",
"field": "--format",
"kind": "string",
"icon": "fa-video",
"order": 1,
"value": "best",
"extras": {"options": ["best", "worst", "720p"]},
},
{
"id": str(uuid.uuid4()),
"name": "audio_only",
"description": "Extract audio only",
"field": "--extract-audio",
"kind": "bool",
"icon": "fa-music",
"order": 2,
"value": "",
"extras": {},
},
]
@patch("app.library.dl_fields.Config")
def test_dl_fields_singleton_behavior(self, mock_config):
"""Test that DLFields follows singleton pattern."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields1 = DLFields()
fields2 = DLFields()
assert fields1 is fields2
@patch("app.library.dl_fields.Config")
def test_dl_fields_get_instance(self, mock_config):
"""Test DLFields.get_instance() method."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields1 = DLFields.get_instance()
fields2 = DLFields.get_instance()
assert fields1 is fields2
@patch("app.library.dl_fields.Config")
@patch("app.library.dl_fields.EventBus")
def test_dl_fields_initialization(self, mock_event_bus, mock_config):
"""Test DLFields initialization."""
mock_config.get_instance.return_value.config_path = "/tmp"
mock_event_bus.get_instance.return_value.subscribe = MagicMock()
with tempfile.TemporaryDirectory() as temp_dir:
temp_file = Path(temp_dir) / "test_fields.json"
fields = DLFields(file=str(temp_file))
assert fields._file == temp_file
assert fields._config is not None
mock_event_bus.get_instance.return_value.subscribe.assert_called_once()
@patch("app.library.dl_fields.Config")
def test_dl_fields_load_empty_file(self, mock_config, temp_file):
"""Test loading from empty/non-existent file."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields(file=str(temp_file))
result = fields.load()
assert result is fields
assert fields.get_all() == []
@patch("app.library.dl_fields.Config")
def test_dl_fields_load_valid_file(self, mock_config, temp_file, sample_fields_data):
"""Test loading from valid JSON file."""
mock_config.get_instance.return_value.config_path = "/tmp"
# Write sample data to temp file
temp_file.write_text(json.dumps(sample_fields_data, indent=2))
fields = DLFields(file=str(temp_file))
fields.load()
loaded_fields = fields.get_all()
assert len(loaded_fields) == 2
# Check first field
field1 = loaded_fields[0]
assert field1.name == "quality"
assert field1.description == "Video quality setting"
assert field1.field == "--format"
assert field1.kind == FieldType.STRING
# Check second field
field2 = loaded_fields[1]
assert field2.name == "audio_only"
assert field2.kind == FieldType.BOOL
@patch("app.library.dl_fields.Config")
def test_dl_fields_load_invalid_json(self, mock_config, temp_file):
"""Test loading from invalid JSON file."""
mock_config.get_instance.return_value.config_path = "/tmp"
# Write invalid JSON
temp_file.write_text("invalid json content")
fields = DLFields(file=str(temp_file))
fields.load()
# Should handle error gracefully and return empty list
assert fields.get_all() == []
@patch("app.library.dl_fields.Config")
def test_dl_fields_load_missing_id_auto_generation(self, mock_config, temp_file):
"""Test that missing IDs are auto-generated during load."""
mock_config.get_instance.return_value.config_path = "/tmp"
# Sample data without IDs
data_without_ids = [
{"name": "test_field", "description": "Test description", "field": "--test", "kind": "string"}
]
temp_file.write_text(json.dumps(data_without_ids))
with patch.object(DLFields, "save") as mock_save:
fields = DLFields(file=str(temp_file))
fields.load()
# Should auto-generate ID and trigger save
loaded_fields = fields.get_all()
assert len(loaded_fields) == 1
assert loaded_fields[0].id is not None
mock_save.assert_called_once()
@patch("app.library.dl_fields.Config")
def test_dl_fields_clear(self, mock_config):
"""Test clearing all fields."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
# Add some fields manually
fields._items = [
DLField(name="test1", description="Test 1", field="--test1"),
DLField(name="test2", description="Test 2", field="--test2"),
]
assert len(fields.get_all()) == 2
result = fields.clear()
assert result is fields
assert len(fields.get_all()) == 0
@patch("app.library.dl_fields.Config")
def test_dl_fields_clear_empty(self, mock_config):
"""Test clearing when already empty."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
assert len(fields.get_all()) == 0
result = fields.clear()
assert result is fields
assert len(fields.get_all()) == 0
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_valid_field(self, mock_config):
"""Test validation with valid DLField."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
valid_field = DLField(
name="valid_field", description="Valid description", field="--valid-option", kind=FieldType.STRING
)
assert fields.validate(valid_field) is True
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_valid_dict(self, mock_config):
"""Test validation with valid dictionary."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
valid_dict = {
"id": str(uuid.uuid4()),
"name": "test_field",
"description": "Test description",
"field": "--test-field",
"kind": "text",
}
assert fields.validate(valid_dict) is True
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_missing_required_fields(self, mock_config):
"""Test validation with missing required fields."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
# Missing name
invalid_dict = {
"id": str(uuid.uuid4()),
"description": "Test description",
"field": "--test-field",
"kind": "text",
}
with pytest.raises(ValueError, match="Missing required key 'name'"):
fields.validate(invalid_dict)
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_invalid_field_type(self, mock_config):
"""Test validation with invalid field type."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
invalid_dict = {
"id": str(uuid.uuid4()),
"name": "test_field",
"description": "Test description",
"field": "--test-field",
"kind": "invalid_type",
}
with pytest.raises(ValueError, match="Invalid field type"):
fields.validate(invalid_dict)
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_invalid_yt_dlp_field(self, mock_config):
"""Test validation with invalid yt-dlp field format."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
invalid_dict = {
"id": str(uuid.uuid4()),
"name": "test_field",
"description": "Test description",
"field": "invalid-field", # Missing --
"kind": "text",
}
with pytest.raises(ValueError, match="Invalid yt-dlp option field"):
fields.validate(invalid_dict)
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_invalid_extras_type(self, mock_config):
"""Test validation with invalid extras type."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
invalid_dict = {
"id": str(uuid.uuid4()),
"name": "test_field",
"description": "Test description",
"field": "--test-field",
"kind": "text",
"extras": "not_a_dict",
}
with pytest.raises(ValueError, match="Extras must be a dictionary"):
fields.validate(invalid_dict)
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_invalid_value_type(self, mock_config):
"""Test validation with invalid value type."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
invalid_dict = {
"id": str(uuid.uuid4()),
"name": "test_field",
"description": "Test description",
"field": "--test-field",
"kind": "text",
"value": 123, # Should be string
}
with pytest.raises(ValueError, match="Value must be a string"):
fields.validate(invalid_dict)
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_invalid_order_type(self, mock_config):
"""Test validation with invalid order type."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
invalid_dict = {
"id": str(uuid.uuid4()),
"name": "test_field",
"description": "Test description",
"field": "--test-field",
"kind": "text",
"order": "not_an_int",
}
with pytest.raises(ValueError, match="Order must be an integer"):
fields.validate(invalid_dict)
@patch("app.library.dl_fields.Config")
def test_dl_fields_validate_unexpected_type(self, mock_config):
"""Test validation with unexpected item type."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
with pytest.raises(ValueError, match="Unexpected 'str' type was given"):
fields.validate("not_a_field_or_dict")
@patch("app.library.dl_fields.Config")
def test_dl_fields_save_valid_fields(self, mock_config, temp_file):
"""Test saving valid fields."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields(file=str(temp_file))
test_fields = [DLField(name="test_field", description="Test description", field="--test-option")]
fields.save(test_fields)
# Verify file was written
assert temp_file.exists()
# Verify content
saved_data = json.loads(temp_file.read_text())
assert len(saved_data) == 1
assert saved_data[0]["name"] == "test_field"
@patch("app.library.dl_fields.Config")
def test_dl_fields_save_mixed_types(self, mock_config, temp_file):
"""Test saving mix of DLField objects and dicts."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields(file=str(temp_file))
test_items = [
DLField(name="field1", description="Field 1", field="--field1"),
{"id": str(uuid.uuid4()), "name": "field2", "description": "Field 2", "field": "--field2", "kind": "text"},
]
fields.save(test_items)
# Verify both items were saved
saved_data = json.loads(temp_file.read_text())
assert len(saved_data) == 2
@patch("app.library.dl_fields.Config")
def test_dl_fields_get_by_id(self, mock_config):
"""Test getting field by ID."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
test_id = str(uuid.uuid4())
test_field = DLField(id=test_id, name="test_field", description="Test description", field="--test-option")
fields._items = [test_field]
result = fields.get(test_id)
assert result is test_field
@patch("app.library.dl_fields.Config")
def test_dl_fields_get_by_name(self, mock_config):
"""Test getting field by name."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
test_field = DLField(name="test_field", description="Test description", field="--test-option")
fields._items = [test_field]
result = fields.get("test_field")
assert result is test_field
@patch("app.library.dl_fields.Config")
def test_dl_fields_get_not_found(self, mock_config):
"""Test getting non-existent field."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
result = fields.get("nonexistent")
assert result is None
@patch("app.library.dl_fields.Config")
def test_dl_fields_get_empty_string(self, mock_config):
"""Test getting with empty string."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
result = fields.get("")
assert result is None
@patch("app.library.dl_fields.Config")
def test_dl_fields_has_by_id(self, mock_config):
"""Test checking field existence by ID."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
test_id = str(uuid.uuid4())
test_field = DLField(id=test_id, name="test_field", description="Test description", field="--test-option")
fields._items = [test_field]
assert fields.has(test_id) is True
assert fields.has("nonexistent") is False
@patch("app.library.dl_fields.Config")
def test_dl_fields_has_by_name(self, mock_config):
"""Test checking field existence by name."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
test_field = DLField(name="test_field", description="Test description", field="--test-option")
fields._items = [test_field]
assert fields.has("test_field") is True
assert fields.has("nonexistent") is False
@patch("app.library.dl_fields.Config")
def test_dl_fields_attach_method(self, mock_config):
"""Test attach method calls load."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
with patch.object(fields, "load") as mock_load:
mock_app = MagicMock()
fields.attach(mock_app)
mock_load.assert_called_once()
@patch("app.library.dl_fields.Config")
def test_dl_fields_on_shutdown(self, mock_config):
"""Test on_shutdown method."""
mock_config.get_instance.return_value.config_path = "/tmp"
fields = DLFields()
mock_app = MagicMock()
# Should not raise any exceptions
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(fields.on_shutdown(mock_app))
finally:
loop.close()

170
app/tests/test_encoder.py Normal file
View file

@ -0,0 +1,170 @@
"""
Tests for encoder.py - JSON encoding utilities.
This test suite provides comprehensive coverage for the Encoder class:
- Tests serialization of various Python types
- Tests special handling of Path objects
- Tests DateRange serialization
- Tests date serialization
- Tests ImpersonateTarget serialization
- Tests ItemDTO serialization
- Tests object serialization with serialize() method
- Tests object serialization with __dict__ fallback
- Tests fallback to default JSONEncoder behavior
Total test functions: 10
All supported types and edge cases are covered.
"""
import json
from datetime import date
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from app.library.encoder import Encoder
class TestEncoder:
"""Test the Encoder class."""
def setup_method(self):
"""Set up test fixtures."""
self.encoder = Encoder()
def test_path_serialization(self):
"""Test that Path objects are serialized as strings."""
path = Path("/tmp/test/file.txt")
result = self.encoder.default(path)
assert result == "/tmp/test/file.txt"
assert isinstance(result, str)
def test_path_serialization_relative(self):
"""Test that relative Path objects are serialized correctly."""
path = Path("relative/path/file.txt")
result = self.encoder.default(path)
assert result == "relative/path/file.txt"
def test_date_serialization(self):
"""Test that date objects are serialized as strings."""
test_date = date(2024, 3, 15)
result = self.encoder.default(test_date)
assert result == "2024-03-15"
def test_object_with_serialize_method(self):
"""Test that objects with serialize method use it."""
class CustomObject:
def serialize(self):
return {"custom": "data", "type": "test"}
obj = CustomObject()
result = self.encoder.default(obj)
assert result == {"custom": "data", "type": "test"}
def test_object_with_dict_fallback(self):
"""Test that objects without serialize method fall back to __dict__."""
class SimpleObject:
def __init__(self):
self.name = "test"
self.value = 42
obj = SimpleObject()
result = self.encoder.default(obj)
assert result == {"name": "test", "value": 42}
def test_object_without_dict_fallback_to_default(self):
"""Test that objects without __dict__ fall back to default JSONEncoder."""
# This should raise TypeError since complex is not JSON serializable
with pytest.raises(TypeError):
self.encoder.default(complex(1, 2))
def test_json_dumps_integration(self):
"""Test full JSON serialization with various types."""
data = {
"path": Path("/tmp/test.txt"),
"date": date(2024, 1, 1),
"number": 42,
"string": "test"
}
result = json.dumps(data, cls=Encoder)
parsed = json.loads(result)
assert parsed["path"] == "/tmp/test.txt"
assert parsed["date"] == "2024-01-01"
assert parsed["number"] == 42
assert parsed["string"] == "test"
def test_json_dumps_with_custom_object(self):
"""Test JSON serialization with custom objects."""
class TestObject:
def __init__(self):
self.name = "test"
self.items = [1, 2, 3]
data = {
"object": TestObject(),
"regular": "data"
}
result = json.dumps(data, cls=Encoder)
parsed = json.loads(result)
assert parsed["object"]["name"] == "test"
assert parsed["object"]["items"] == [1, 2, 3]
assert parsed["regular"] == "data"
def test_nested_serialization(self):
"""Test serialization of nested structures with various types."""
class CustomObj:
def serialize(self):
return {"serialized": True}
data = {
"paths": [Path("/tmp/1.txt"), Path("/tmp/2.txt")],
"dates": [date(2024, 1, 1), date(2024, 12, 31)],
"custom": CustomObj(),
"nested": {
"path": Path("/nested/path"),
"date": date(2024, 6, 15)
}
}
result = json.dumps(data, cls=Encoder)
parsed = json.loads(result)
assert parsed["paths"] == ["/tmp/1.txt", "/tmp/2.txt"]
assert parsed["dates"] == ["2024-01-01", "2024-12-31"]
assert parsed["custom"] == {"serialized": True}
assert parsed["nested"]["path"] == "/nested/path"
assert parsed["nested"]["date"] == "2024-06-15"
def test_mock_daterange_serialization(self):
"""Test DateRange serialization with mock object."""
# Mock a DateRange-like object
mock_daterange = MagicMock()
mock_daterange.start = date(2024, 1, 15)
mock_daterange.end = date(2024, 12, 31)
# Mock isinstance to return True for DateRange
import builtins
original_isinstance = builtins.isinstance
def mock_isinstance(obj, cls):
if obj is mock_daterange and hasattr(cls, "__name__") and "DateRange" in str(cls):
return True
return original_isinstance(obj, cls)
builtins.isinstance = mock_isinstance
try:
result = self.encoder.default(mock_daterange)
expected = {"start": "20240115", "end": "20241231"}
assert result == expected
finally:
builtins.isinstance = original_isinstance
if __name__ == "__main__":
pytest.main([__file__])

1014
app/tests/test_events.py Normal file

File diff suppressed because it is too large Load diff

255
app/tests/test_singleton.py Normal file
View file

@ -0,0 +1,255 @@
"""
Tests for Singleton.py - Singleton metaclass utilities.
This test suite provides comprehensive coverage for the singleton metaclasses:
- Tests Singleton metaclass behavior
- Tests ThreadSafe metaclass behavior
- Tests thread safety of ThreadSafe metaclass
- Tests that different classes get different instances
- Tests that same class gets same instance
Total test functions: 8
All singleton patterns and edge cases are covered.
"""
import threading
import time
from app.library.Singleton import Singleton, ThreadSafe
class TestSingleton:
"""Test the Singleton metaclass."""
def setup_method(self):
"""Set up test fixtures by clearing singleton instances."""
# Clear singleton instances before each test
Singleton._instances.clear()
ThreadSafe._instances.clear()
def test_singleton_same_instance(self):
"""Test that Singleton returns same instance for same class."""
class TestClass(metaclass=Singleton):
def __init__(self, value=None):
self.value = value
instance1 = TestClass("first")
instance2 = TestClass("second")
# Should be the same instance
assert instance1 is instance2
# First initialization should win
assert instance1.value == "first"
assert instance2.value == "first"
def test_singleton_different_classes(self):
"""Test that Singleton creates different instances for different classes."""
class ClassA(metaclass=Singleton):
def __init__(self):
self.name = "A"
class ClassB(metaclass=Singleton):
def __init__(self):
self.name = "B"
instance_a1 = ClassA()
instance_a2 = ClassA()
instance_b1 = ClassB()
instance_b2 = ClassB()
# Same class should return same instance
assert instance_a1 is instance_a2
assert instance_b1 is instance_b2
# Different classes should return different instances
assert instance_a1 is not instance_b1
assert instance_a1.name == "A"
assert instance_b1.name == "B"
def test_threadsafe_same_instance(self):
"""Test that ThreadSafe returns same instance for same class."""
class TestClass(metaclass=ThreadSafe):
def __init__(self, value=None):
self.value = value
instance1 = TestClass("first")
instance2 = TestClass("second")
# Should be the same instance
assert instance1 is instance2
# First initialization should win
assert instance1.value == "first"
assert instance2.value == "first"
def test_threadsafe_different_classes(self):
"""Test that ThreadSafe creates different instances for different classes."""
class ClassA(metaclass=ThreadSafe):
def __init__(self):
self.name = "A"
class ClassB(metaclass=ThreadSafe):
def __init__(self):
self.name = "B"
instance_a1 = ClassA()
instance_a2 = ClassA()
instance_b1 = ClassB()
instance_b2 = ClassB()
# Same class should return same instance
assert instance_a1 is instance_a2
assert instance_b1 is instance_b2
# Different classes should return different instances
assert instance_a1 is not instance_b1
assert instance_a1.name == "A"
assert instance_b1.name == "B"
def test_threadsafe_thread_safety(self):
"""Test that ThreadSafe is actually thread-safe."""
instances = []
errors = []
class ThreadSafeClass(metaclass=ThreadSafe):
def __init__(self):
# Add a small delay to increase chance of race condition
time.sleep(0.01)
self.thread_id = threading.current_thread().ident
self.creation_time = time.time()
def worker():
try:
instance = ThreadSafeClass()
instances.append(instance)
except Exception as e:
errors.append(str(e))
# Create multiple threads trying to create instances simultaneously
threads = []
for _ in range(10):
thread = threading.Thread(target=worker)
threads.append(thread)
# Start all threads at roughly the same time
for thread in threads:
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Check results
assert len(errors) == 0, f"Thread safety errors: {errors}"
assert len(instances) == 10
# All instances should be the same object
first_instance = instances[0]
for instance in instances[1:]:
assert instance is first_instance
# Only one creation should have happened
assert hasattr(first_instance, "thread_id")
assert hasattr(first_instance, "creation_time")
def test_singleton_inheritance(self):
"""Test singleton behavior with inheritance."""
class BaseClass(metaclass=Singleton):
def __init__(self):
self.base_value = "base"
class ChildClass(BaseClass):
def __init__(self):
super().__init__()
self.child_value = "child"
# Each class should have its own singleton instance
base1 = BaseClass()
base2 = BaseClass()
child1 = ChildClass()
child2 = ChildClass()
# Same class instances should be identical
assert base1 is base2
assert child1 is child2
# Different class instances should be different
assert base1 is not child1
# Check values
assert base1.base_value == "base"
assert child1.base_value == "base"
assert child1.child_value == "child"
def test_threadsafe_inheritance(self):
"""Test threadsafe singleton behavior with inheritance."""
class BaseClass(metaclass=ThreadSafe):
def __init__(self):
self.base_value = "base"
class ChildClass(BaseClass):
def __init__(self):
super().__init__()
self.child_value = "child"
# Each class should have its own singleton instance
base1 = BaseClass()
base2 = BaseClass()
child1 = ChildClass()
child2 = ChildClass()
# Same class instances should be identical
assert base1 is base2
assert child1 is child2
# Different class instances should be different
assert base1 is not child1
# Check values
assert base1.base_value == "base"
assert child1.base_value == "base"
assert child1.child_value == "child"
def test_singleton_with_args_and_kwargs(self):
"""Test singleton behavior with various constructor arguments."""
class ConfigClass(metaclass=Singleton):
def __init__(self, name, value=None, **kwargs):
self.name = name
self.value = value
self.extra = kwargs
# First instantiation with arguments
instance1 = ConfigClass("test", value=42, extra_param="extra")
# Second instantiation with different arguments (should be ignored)
instance2 = ConfigClass("different", value=100, other_param="other")
# Should be same instance with original values
assert instance1 is instance2
assert instance1.name == "test"
assert instance1.value == 42
assert instance1.extra == {"extra_param": "extra"}
def test_threadsafe_with_args_and_kwargs(self):
"""Test threadsafe singleton behavior with various constructor arguments."""
class ConfigClass(metaclass=ThreadSafe):
def __init__(self, name, value=None, **kwargs):
self.name = name
self.value = value
self.extra = kwargs
# First instantiation with arguments
instance1 = ConfigClass("test", value=42, extra_param="extra")
# Second instantiation with different arguments (should be ignored)
instance2 = ConfigClass("different", value=100, other_param="other")
# Should be same instance with original values
assert instance1 is instance2
assert instance1.name == "test"
assert instance1.value == 42
assert instance1.extra == {"extra_param": "extra"}
if __name__ == "__main__":
import pytest
pytest.main([__file__])

1500
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.
@ -92,6 +90,9 @@ installer = [
select = [
"ALL", # include all the rules, including new ones
]
# Ignore some rules in test files.
per-file-ignores = { "test_*.py" = ["D"] }
ignore = [
#### modules
"ANN", # flake8-annotations
@ -159,6 +160,7 @@ ignore = [
"LOG015",
"PLC0415",
"S603",
"D203",
]
# Allow fix for all enabled rules (when `--fix`) is provided.
@ -200,3 +202,11 @@ docstring-code-line-length = "dynamic"
[tool.uv]
link-mode = "copy"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["app/tests"]
addopts = "-v --tb=short"
[dependency-groups]
dev = ["pytest>=8.4.2", "pytest-asyncio>=1.1.0", "ruff>=0.13.0"]

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
@ -503,7 +505,8 @@ const addExtra = (): void => {
}
const removeExtra = (key: string): void => {
delete form.extras[key]
const { [key]: _, ...rest } = form.extras
form.extras = rest
}
const updateExtraKey = (event: Event, oldKey: string): void => {
@ -528,8 +531,8 @@ const updateExtraKey = (event: Event, oldKey: string): void => {
}
const value = form.extras[oldKey]
delete form.extras[oldKey]
form.extras[newKey] = value
const { [oldKey]: _, ...rest } = form.extras
form.extras = { ...rest, [newKey]: value }
}
}

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>
@ -35,7 +35,7 @@ const url = ref<string | null>(null)
const error = ref(false)
const isPreloading = ref(false)
let loadTimer: ReturnType<typeof setTimeout> | null = null
const loadTimer: ReturnType<typeof setTimeout> | null = null
const cancelRequest = new AbortController()
const defaultLoader = async (): Promise<void> => {

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 }
@ -615,7 +615,7 @@ const deleteSelectedItems = async () => {
}
const clearCompleted = async () => {
let msg = 'Clear all completed downloads?'
const msg = 'Clear all completed downloads?'
if (false === (await box.confirm(msg))) {
return
}
@ -774,7 +774,7 @@ const removeItem = async (item: StoreItem) => {
}
const retryItem = (item: StoreItem, re_add = false) => {
let item_req: Partial<StoreItem> = {
const item_req: Partial<StoreItem> = {
url: item.url,
preset: item.preset,
folder: item.folder,
@ -801,10 +801,12 @@ const retryItem = (item: StoreItem, re_add = false) => {
const pImg = (e: Event) => {
const target = e.target as HTMLImageElement
target.naturalHeight > target.naturalWidth && target.classList.add('image-portrait')
if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait')
}
}
watch(video_item, v => {
watch(video_item, (v) => {
if (!bg_enable.value) {
return
}
@ -823,7 +825,7 @@ const downloadSelected = async () => {
toast.error('No items selected.')
return
}
let files_list: string[] = []
const files_list: string[] = []
for (const key in selectedElms.value) {
const item_id = selectedElms.value[key]
if (!item_id) {

View file

@ -29,8 +29,9 @@ function onIdle(cb: () => void): void {
}
}
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
if (isIntersecting) {
const { stop } = useIntersectionObserver(targetEl, (entries) => {
const entry = entries[0]
if (entry?.isIntersecting) {
if (unrenderTimer) clearTimeout(unrenderTimer)
renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0)

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>
@ -377,7 +377,7 @@ const checkInfo = async () => {
if (!isApprise.value) {
try {
new URL(form.request.url)
} catch (_) {
} catch {
toast.error('Invalid URL')
return
}

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

@ -427,7 +427,6 @@ const setStatus = (item: StoreItem): string => {
return 'Streaming'
}
if ('preparing' === item.status) {
// @ts-ignore
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'
}
if (!item.status) {
@ -494,7 +493,6 @@ const updateProgress = (item: StoreItem): string => {
return 'Post-processors are running.'
}
if ('preparing' === item.status) {
// @ts-ignore
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
}
if (null != item.status) {
@ -547,7 +545,7 @@ const startItems = async () => {
if (1 > selectedElms.value.length) {
return
}
let filtered: string[] = []
const filtered: string[] = []
selectedElms.value.forEach(id => {
const item = stateStore.get('queue', id) as StoreItem
if (item && !item.auto_start && !item.status) {
@ -569,7 +567,7 @@ const pauseSelected = async () => {
if (1 > selectedElms.value.length) {
return
}
let filtered: string[] = []
const filtered: string[] = []
selectedElms.value.forEach(id => {
const item = stateStore.get('queue', id) as StoreItem
if (item && item.auto_start && !item.status) {
@ -589,7 +587,9 @@ const pauseSelected = async () => {
const pImg = (e: Event) => {
const target = e.target as HTMLImageElement
target.naturalHeight > target.naturalWidth ? target.classList.add('image-portrait') : null
if (target.naturalHeight > target.naturalWidth) {
target.classList.add('image-portrait')
}
}
watch(embed_url, v => {

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

@ -238,14 +238,14 @@ onMounted(async () => {
sources.value.push({
src,
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
onerror: (e: Event) => src_error(),
onerror: (_e: Event) => src_error(),
})
} else {
const src = makeDownload(config, props.item, 'api/download')
sources.value.push({
src,
type: response.mimetype,
onerror: e => src_error(),
onerror: (_e: Event) => src_error(),
})
}

View file

@ -16,6 +16,7 @@ export interface notificationOptions {
force?: boolean,
closeOnClick?: boolean,
position?: POSITION
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
onClick?: (closeToast: Function) => void
store?: boolean,
lowPriority?: boolean
@ -52,6 +53,7 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
opts.closeOnClick = toastDismissOnClick.value
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
opts.onClick = (closeToast: Function) => {
if (opts?.closeOnClick !== false) {
closeToast()

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>
@ -251,7 +251,7 @@ const applyPreferredColorScheme = (scheme: string) => {
onMounted(async () => {
try {
await handleImage(bg_enable.value)
} catch (e) { }
} catch { }
try {
const opts = await request('/api/yt-dlp/options')
@ -260,11 +260,11 @@ onMounted(async () => {
}
const data: Array<YTDLPOption> = await opts.json()
config.ytdlp_options = data
} catch (e) { }
} catch { }
try {
applyPreferredColorScheme(selectedTheme.value)
} catch (e) { }
} catch { }
})
watch(selectedTheme, value => {
@ -274,7 +274,7 @@ watch(selectedTheme, value => {
return
}
applyPreferredColorScheme(value)
} catch (e) { }
} catch { }
})
const reloadPage = () => window.location.reload()
@ -362,7 +362,7 @@ const loadImage = async (force = false) => {
}
}
const changeRoute = async (_: MouseEvent, callback: Function | null = null) => {
const changeRoute = async (_: MouseEvent, callback: (() => void) | null = null) => {
showMenu.value = false
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
if (callback) {

View file

@ -262,7 +262,7 @@ const show_filter = ref<boolean>(false)
const reset_dialog = () => ({
visible: false,
title: 'Confirm Action',
confirm: (opts: any) => { },
confirm: (_opts: any) => { },
message: '',
html_message: '',
options: [],
@ -317,8 +317,8 @@ const sortedItems = (items: FileItem[]): FileItem[] => {
if (sort_by.value === 'date') {
return items.sort((a, b) => {
let aDate = new Date(a.mtime)
let bDate = new Date(b.mtime)
const aDate = new Date(a.mtime)
const bDate = new Date(b.mtime)
return 'asc' === sort_order.value ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime()
})
}
@ -479,7 +479,7 @@ const makeBreadCrumb = (path: string): { name: string, link: string, path: strin
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
let links = []
const links = []
links.push({
name: 'Home',
link: baseLink,
@ -487,9 +487,9 @@ const makeBreadCrumb = (path: string): { name: string, link: string, path: strin
})
// -- explode path and create links
let parts = path.split('/')
const parts = path.split('/')
parts.forEach((part, index) => {
let path = baseLink + parts.slice(0, index + 1).join('/')
const path = baseLink + parts.slice(0, index + 1).join('/')
links.push({
name: part,
link: path,
@ -570,12 +570,12 @@ const createDirectory = async (dir: string): Promise<void> => {
return
}
let new_dir = sTrim(newDir, '/')
const new_dir = sTrim(newDir, '/')
if (!new_dir || new_dir === dir) {
return
}
await actionRequest({ path: dir || '/' } as FileItem, 'directory', { new_dir: new_dir }, (item, action, data) => {
await actionRequest({ path: dir || '/' } as FileItem, 'directory', { new_dir: new_dir }, (_item, _action, _data) => {
reloadContent(path.value, true)
toast.success(`Successfully created '${new_dir}'.`)
})
@ -598,7 +598,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
let new_name = newName.trim()
const new_name = newName.trim()
if (!new_name || new_name === item.name) {
return
}
@ -625,7 +625,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
await actionRequest(item, 'delete', {}, (item, action, data) => {
await actionRequest(item, 'delete', {}, (item, _action, _data) => {
items.value = items.value.filter(i => i.path !== item.path)
toast.warning(`Deleted '${item.name}'.`)
})
@ -646,7 +646,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
return
}
let new_path = sTrim(newPath, '/') || '/'
const new_path = sTrim(newPath, '/') || '/'
if (!new_path || new_path === item.path) {
return
}

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>
@ -403,10 +403,11 @@ const exportItem = async (item: notification) => {
_version: '1.0',
}
const keys = ['id', 'raw']
const keys = ['id', 'raw'] as const
keys.forEach(k => {
if (Object.prototype.hasOwnProperty.call(data, k)) {
delete (data as any)[k]
const { [k]: _, ...rest } = data as any
Object.assign(data, rest)
}
})

View file

@ -379,7 +379,8 @@ const exportItem = (item: Preset) => {
for (const key of keys) {
if (key in data) {
delete data[key]
const { [key]: _, ...rest } = data
Object.assign(data, rest)
}
}

View file

@ -394,7 +394,7 @@ const display_style = useStorage<string>("tasks_display_style", "cards")
const isMobile = useMediaQuery({ maxWidth: 1024 })
const tasks = ref<Array<task_item>>([])
const task = ref<task_item | Object>({})
const task = ref<task_item | Record<string, unknown>>({})
const taskRef = ref<string>('')
const toggleForm = ref<boolean>(false)
const isLoading = ref<boolean>(true)
@ -409,7 +409,7 @@ const table_container = ref(false)
const reset_dialog = () => ({
visible: false,
title: 'Confirm Action',
confirm: (opts: any) => { },
confirm: (_opts: any) => { },
message: '',
html_message: '',
options: [],
@ -649,7 +649,7 @@ onMounted(async () => {
const tryParse = (expression: string) => {
try {
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
} catch (e) {
} catch {
return "Invalid"
}
}
@ -706,7 +706,7 @@ const runNow = async (item: task_item, mass: boolean = false) => {
item.in_progress = true
}
let data = {
const data = {
url: item.url,
preset: item.preset,
} as task_item
@ -754,7 +754,7 @@ const statusHandler = async (stream: string) => {
const exportItem = async (item: task_item) => {
const info = JSON.parse(JSON.stringify(item))
let data = {
const data = {
name: info.name,
url: info.url,
preset: info.preset,
@ -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();

View file

@ -1,6 +1,4 @@
import { io } from "socket.io-client";
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
import type { ManagerOptions } from "socket.io-client";
import { io, type Socket as IOSocket, type SocketOptions, type ManagerOptions } from "socket.io-client"
import type { ConfigState } from "~/types/config";
import type { StoreItem } from "~/types/store";
@ -29,7 +27,7 @@ export const useSocketStore = defineStore('socket', () => {
}
const connect = () => {
let opts = {
const opts = {
transports: ['websocket', 'polling'],
withCredentials: true,
} as Partial<ManagerOptions & SocketOptions>

View file

@ -22,7 +22,8 @@ export const useStateStore = defineStore('state', () => {
const remove = (type: StateType, key: KeyType): void => {
if (state[type][key]) {
delete state[type][key]
const { [key]: _, ...rest } = state[type]
state[type] = rest
}
}
@ -45,7 +46,8 @@ export const useStateStore = defineStore('state', () => {
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
if (state[fromType][key]) {
state[toType][key] = state[fromType][key]
delete state[fromType][key]
const { [key]: _, ...rest } = state[fromType]
state[fromType] = rest
}
}

View file

@ -20,7 +20,7 @@ type DLField = {
icon?: string;
/** The order of the field, used to sort the fields in the UI. */
order: number = 1;
order: number;
/** The default value of the field, It's currently unused */
value: string;

View file

@ -1,8 +1,8 @@
export type Event = {
id: str
created_at: str
event: str
title: str | null = null
message: str | null = null
data: Any = { }
id: string
created_at: string
event: string
title: string | null
message: string | null
data: Record<string, unknown>
}

View file

@ -8,7 +8,7 @@ const sources: EmbedSource[] = [
{
name: 'youtube',
url: 'https://www.youtube-nocookie.com/embed/{id}',
regex: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:shorts\/|[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{11})/,
regex: /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:shorts\/|[^/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)(?<id>[a-zA-Z0-9_-]{11})/,
},
{
name: "instagram_post",

View file

@ -234,6 +234,7 @@ const request = (url: string, options: RequestInit = {}): Promise<Response> => {
* @returns A string without ANSI escape sequences.
*/
const removeANSIColors = (text: string): string => {
// eslint-disable-next-line no-control-regex
return text?.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') ?? text
}
@ -601,6 +602,7 @@ const sleep = (seconds: number): Promise<void> => new Promise(resolve => setTime
*
* @returns The result of the test function.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
const awaiter = async (test: Function, timeout_ms: number = 20 * 1000, frequency: number = 200) => {
if (typeof (test) != "function") {
throw new Error("test should be a function in awaiter(test, [timeout_ms], [frequency])")

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 }

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

@ -0,0 +1,54 @@
// 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',
"@typescript-eslint/no-explicit-any": 'off',
}
},
{
files: ['**/*.vue'],
rules: {}
})

View file

@ -14,8 +14,7 @@ try {
}
}
}
catch (e) {
}
catch { }
export default defineNuxtConfig({
ssr: false,
@ -60,6 +59,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@vueuse/nuxt',
'floating-vue/nuxt',
process.env.NODE_ENV === 'development' ? '@nuxt/eslint' : '',
],
nitro: {
@ -76,6 +76,6 @@ export default defineNuxtConfig({
telemetry: false,
compatibilityDate: "2025-08-03",
experimental: {
checkOutdatedBuildInterval: 1000 * 60 * 60,
checkOutdatedBuildInterval: 1000 * 60 * 10,
}
})

View file

@ -7,7 +7,14 @@
"dev": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck",
"lint": "eslint .",
"lint:ci": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:ci": "vitest run --silent --reporter=dot"
},
"web-types": "./web-types.json",
"dependencies": {
@ -17,9 +24,9 @@
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cron-parser": "^5.3.1",
"cronstrue": "^3.2.0",
"cronstrue": "^3.3.0",
"floating-vue": "^5.2.2",
"hls.js": "^1.6.11",
"hls.js": "^1.6.12",
"moment": "^2.30.1",
"nuxt": "^4.1.1",
"pinia": "^3.0.3",
@ -39,5 +46,15 @@
"strip-ansi": "7.1.0",
"ansi-styles": "6.2.0",
"ansi-regex": "6.2.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"@nuxt/eslint-config": "^1.9.0",
"@typescript-eslint/parser": "^8.43.0",
"eslint": "^9.35.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.0.6"
}
}

File diff suppressed because it is too large Load diff

95
uv.lock
View file

@ -626,6 +626,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
]
[[package]]
name = "macholib"
version = "1.16.3"
@ -765,6 +774,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "propcache"
version = "0.3.2"
@ -877,6 +895,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pyinstaller"
version = "6.15.0"
@ -998,6 +1025,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/99/09/0fc0719162e5ad723f71d41cf336f18b6b5054d70dc0fe42ace6b4d2bdc9/pysubs2-1.8.0-py3-none-any.whl", hash = "sha256:05716f5039a9ebe32cd4d7673f923cf36204f3a3e99987f823ab83610b7035a0", size = 43516 },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
]
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -1207,6 +1262,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
]
[[package]]
name = "ruff"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826 },
{ url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428 },
{ url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543 },
{ url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489 },
{ url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631 },
{ url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602 },
{ url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751 },
{ url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317 },
{ url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418 },
{ url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843 },
{ url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891 },
{ url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119 },
{ url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594 },
{ url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377 },
{ url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555 },
{ url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613 },
{ url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250 },
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357 },
]
[[package]]
name = "setuptools"
version = "80.9.0"
@ -1430,6 +1511,13 @@ installer = [
{ name = "pyinstaller" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiocron", specifier = ">=1.8" },
@ -1466,6 +1554,13 @@ requires-dist = [
]
provides-extras = ["installer"]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-asyncio", specifier = ">=1.1.0" },
{ name = "ruff", specifier = ">=0.13.0" },
]
[[package]]
name = "zipstream-ng"
version = "1.9.0"