Merge pull request #416 from arabcoders/dev
Mostly CI improvements to include tests and linting
This commit is contained in:
commit
049b9a4e38
75 changed files with 9217 additions and 649 deletions
61
.github/workflows/build-pr.yml
vendored
61
.github/workflows/build-pr.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
113
.github/workflows/main.yml
vendored
113
.github/workflows/main.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -37,3 +37,5 @@ __pycache__
|
|||
dist
|
||||
build
|
||||
version.txt
|
||||
test_impl.py
|
||||
./eslint.config.js
|
||||
|
|
|
|||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -87,6 +87,6 @@
|
|||
}
|
||||
],
|
||||
"justMyCode": true
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
33
.vscode/settings.json
vendored
33
.vscode/settings.json
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
297
app/library/mini_filter.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import operator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
TOKEN = tuple[str, str]
|
||||
AST_NODE = tuple[str, ...]
|
||||
|
||||
|
||||
def match_str(expr: str, dct: dict) -> bool:
|
||||
"""
|
||||
Convenience function to evaluate a filter expression against a dict.
|
||||
|
||||
Note: This implementation uses numeric duration/filesize values in tests to avoid
|
||||
yt-dlp's inconsistent unit parsing behavior. yt-dlp's match_str has known issues
|
||||
with duration unit formats like "2m" that don't behave consistently with their
|
||||
numeric equivalents (e.g., "120").
|
||||
|
||||
Args:
|
||||
expr (str): Filter expression string
|
||||
dct (dict): Dictionary of values to check against
|
||||
|
||||
Returns:
|
||||
bool: True/False if expression matches
|
||||
|
||||
"""
|
||||
return MiniFilter(expr).evaluate(dct)
|
||||
|
||||
|
||||
class MiniFilter:
|
||||
"""
|
||||
Parser and evaluator for yt-dlp style match-filters, extended with
|
||||
support for OR (`||`) and grouping with parentheses.
|
||||
|
||||
Features:
|
||||
- Supports AND (`&`), OR (`||`), and parentheses `()`
|
||||
- Reuses yt-dlp style operators (=, >=, *=, ~=, etc.)
|
||||
- Export to yt-dlp compatible `--match-filters`.
|
||||
"""
|
||||
|
||||
# Type aliases for better readability
|
||||
Token = tuple[str, str]
|
||||
ASTNode = tuple[str, ...]
|
||||
|
||||
# Supported string operators
|
||||
STRING_OPERATORS: dict[str, callable] = {
|
||||
"*=": operator.contains,
|
||||
"^=": lambda attr, value: isinstance(attr, str) and attr.startswith(value),
|
||||
"$=": lambda attr, value: isinstance(attr, str) and attr.endswith(value),
|
||||
"~=": lambda attr, value: bool(re.search(value, attr or "")),
|
||||
}
|
||||
|
||||
# Comparison operators (numeric + string)
|
||||
COMPARISON_OPERATORS: dict[str, callable] = {
|
||||
**STRING_OPERATORS,
|
||||
"<=": operator.le,
|
||||
"<": operator.lt,
|
||||
">=": operator.ge,
|
||||
">": operator.gt,
|
||||
"=": operator.eq,
|
||||
}
|
||||
|
||||
# Unary operators (for presence/absence checks)
|
||||
UNARY_OPERATORS: dict[str, callable] = {
|
||||
"": lambda v: (v is True) if isinstance(v, bool) else (v is not None),
|
||||
"!": lambda v: (v is False) if isinstance(v, bool) else (v is None),
|
||||
}
|
||||
|
||||
def __init__(self, expr: str) -> None:
|
||||
"""
|
||||
Initialize a parser for the given filter expression.
|
||||
|
||||
:param expr: Filter expression string, e.g. "(duration<10m & filesize>1MB) || uploader*='BBC'"
|
||||
"""
|
||||
self.expr: str = expr
|
||||
self.tokens: list[TOKEN] = self._tokenize(expr)
|
||||
self.pos: int = 0
|
||||
self.ast: AST_NODE = self._parse_or()
|
||||
|
||||
@staticmethod
|
||||
def run(expr: str, dct: dict | bool = False) -> bool:
|
||||
"""
|
||||
Convenience method to evaluate an expression directly.
|
||||
|
||||
:param expr: Filter expression string
|
||||
:param dct: Dictionary of values to check against
|
||||
:return: True/False if expression matches
|
||||
"""
|
||||
return MiniFilter(expr).evaluate(dct)
|
||||
|
||||
def evaluate(self, dct: dict | bool = False) -> bool:
|
||||
"""
|
||||
Evaluate the parsed expression against a dictionary.
|
||||
|
||||
:param dct: Dictionary of attributes (video metadata, etc.)
|
||||
:return: True/False result of expression
|
||||
"""
|
||||
return self._eval(self.ast, dct)
|
||||
|
||||
def export(self) -> list[str]:
|
||||
"""
|
||||
Export expression into yt-dlp compatible `--match-filters` strings.
|
||||
|
||||
Since yt-dlp does not support OR (`||`) or parentheses,
|
||||
this method flattens the expression into multiple AND-only strings.
|
||||
|
||||
Example:
|
||||
(a=1 & b=2) || c=3
|
||||
→ ["a=1&b=2", "c=3"]
|
||||
|
||||
:return: List of AND-only filter strings
|
||||
|
||||
"""
|
||||
return ["&".join(parts) for parts in self._export(self.ast)]
|
||||
|
||||
def _tokenize(self, expr: str) -> list[TOKEN]:
|
||||
"""
|
||||
Split expression into tokens (AND, OR, LPAREN, RPAREN, ATOM).
|
||||
Ensures that OR (||) and AND (&) are recognized before ATOM.
|
||||
"""
|
||||
# First, let's normalize spaces around operators to make parsing easier
|
||||
# Replace spaced operators with non-spaced ones
|
||||
normalized_expr: str = expr
|
||||
for op in ["<=", ">=", "<", ">", "*=", "^=", "$=", "~=", "="]:
|
||||
# Replace "key op value" with "key op value" (removing extra spaces)
|
||||
pattern: str = rf"([a-z_]+)\s*(!?)\s*({re.escape(op)})\s*"
|
||||
replacement = r"\1\2\3"
|
||||
normalized_expr = re.sub(pattern, replacement, normalized_expr)
|
||||
|
||||
token_spec: list[set] = [
|
||||
(r"\|\|", "OR"), # must come before ATOM
|
||||
(r"&", "AND"),
|
||||
(r"\(", "LPAREN"),
|
||||
(r"\)", "RPAREN"),
|
||||
(r"[^\s&|()]+", "ATOM"), # don't allow whitespace in ATOM
|
||||
(r"\s+", None), # skip whitespace
|
||||
]
|
||||
regex: str = "|".join(f"(?P<{name}>{pat})" for pat, name in token_spec if name)
|
||||
master: re.Pattern[str] = re.compile(regex)
|
||||
tokens: list[TOKEN] = []
|
||||
for m in master.finditer(normalized_expr):
|
||||
kind: str | None = m.lastgroup
|
||||
if kind:
|
||||
tokens.append((kind, m.group()))
|
||||
return tokens
|
||||
|
||||
def _parse_or(self) -> AST_NODE:
|
||||
"""
|
||||
Parse OR-expression: and_expr ('||' and_expr)*
|
||||
"""
|
||||
left: AST_NODE = self._parse_and()
|
||||
while self._accept("OR"):
|
||||
right: AST_NODE = self._parse_and()
|
||||
left = ("OR", left, right)
|
||||
return left
|
||||
|
||||
def _parse_and(self) -> AST_NODE:
|
||||
"""
|
||||
Parse AND-expression: atom ('&' atom)*
|
||||
"""
|
||||
left: AST_NODE = self._parse_atom()
|
||||
while self._accept("AND"):
|
||||
right: AST_NODE = self._parse_atom()
|
||||
left = ("AND", left, right)
|
||||
return left
|
||||
|
||||
def _parse_atom(self) -> AST_NODE:
|
||||
"""
|
||||
Parse atomic expression: '(' expr ')' | ATOM
|
||||
"""
|
||||
if self._accept("LPAREN"):
|
||||
node: AST_NODE = self._parse_or()
|
||||
self._expect("RPAREN")
|
||||
return node
|
||||
|
||||
tok: TOKEN = self._expect("ATOM")
|
||||
return ("ATOM", tok[1].strip())
|
||||
|
||||
def _accept(self, kind: str) -> bool:
|
||||
"""
|
||||
Consume token if it matches expected kind.
|
||||
"""
|
||||
if self.pos < len(self.tokens) and kind == self.tokens[self.pos][0]:
|
||||
self.pos += 1
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _expect(self, kind: str) -> TOKEN:
|
||||
"""
|
||||
Consume and return token if it matches expected kind,
|
||||
otherwise raise SyntaxError.
|
||||
"""
|
||||
if self.pos < len(self.tokens) and kind == self.tokens[self.pos][0]:
|
||||
tok = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return tok
|
||||
|
||||
raise SyntaxError("Expected " + kind)
|
||||
|
||||
def _eval(self, node: AST_NODE, dct: dict) -> bool:
|
||||
"""
|
||||
Recursively evaluate AST node against dict.
|
||||
"""
|
||||
node_type: str = node[0]
|
||||
|
||||
if "ATOM" == node_type:
|
||||
return self._match_one(node[1], dct)
|
||||
|
||||
if "AND" == node_type:
|
||||
return self._eval(node[1], dct) and self._eval(node[2], dct)
|
||||
|
||||
if "OR" == node_type:
|
||||
return self._eval(node[1], dct) or self._eval(node[2], dct)
|
||||
|
||||
raise ValueError("Invalid AST node " + node_type)
|
||||
|
||||
def _export(self, node: AST_NODE) -> list[list[str]]:
|
||||
"""
|
||||
Recursively flatten AST into AND-only filter groups.
|
||||
"""
|
||||
node_type = node[0]
|
||||
if "ATOM" == node_type:
|
||||
return [[node[1]]]
|
||||
|
||||
if "AND" == node_type:
|
||||
left: list[list[str]] = self._export(node[1])
|
||||
right: list[list[str]] = self._export(node[2])
|
||||
return [_l + _r for _l in left for _r in right]
|
||||
|
||||
if "OR" == node_type:
|
||||
left = self._export(node[1])
|
||||
right = self._export(node[2])
|
||||
return left + right
|
||||
|
||||
raise ValueError("Invalid AST node " + node_type)
|
||||
|
||||
def _match_one(self, filter_part: str, dct: dict) -> bool:
|
||||
operator_rex: re.Pattern[str] = re.compile(
|
||||
r"""(?x)
|
||||
(?P<key>[a-z_]+)
|
||||
\s*(?P<negation>!\s*)?(?P<op>{})(?P<none_inclusive>\s*\?)?\s*
|
||||
(?:
|
||||
(?P<quote>["\'])(?P<quoted_strval>.+?)(?P=quote)|
|
||||
(?P<strval>.+?)
|
||||
)
|
||||
""".format("|".join(map(re.escape, self.COMPARISON_OPERATORS.keys())))
|
||||
)
|
||||
|
||||
if m := operator_rex.fullmatch(filter_part.strip()):
|
||||
from yt_dlp.utils import parse_duration, parse_filesize
|
||||
|
||||
g: dict[str, str | Any] = m.groupdict()
|
||||
unnegated_op: Any = self.COMPARISON_OPERATORS[g["op"]]
|
||||
op = (lambda a, v: not unnegated_op(a, v)) if g["negation"] else unnegated_op
|
||||
|
||||
comparison_value: str | Any = g["quoted_strval"] or g["strval"]
|
||||
|
||||
if g["quote"]:
|
||||
comparison_value = comparison_value.replace(rf"\{g['quote']}", g["quote"])
|
||||
|
||||
actual_value: Any | None = dct.get(g["key"])
|
||||
|
||||
if None is actual_value:
|
||||
return False
|
||||
|
||||
# numeric coercion using yt-dlp utils
|
||||
numeric_comparison = None
|
||||
try:
|
||||
numeric_comparison = int(comparison_value)
|
||||
except ValueError:
|
||||
numeric_comparison: int | None | float = parse_filesize(comparison_value) or parse_duration(
|
||||
comparison_value
|
||||
)
|
||||
|
||||
if numeric_comparison is not None and g["op"] in self.STRING_OPERATORS:
|
||||
msg = f"Operator {g['op']} only supports string values!"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Also try to convert actual_value to numeric if we have a numeric comparison
|
||||
final_actual_value = actual_value
|
||||
if numeric_comparison is not None and isinstance(actual_value, str):
|
||||
try:
|
||||
final_actual_value = int(actual_value)
|
||||
except ValueError:
|
||||
final_actual_value = parse_filesize(actual_value) or parse_duration(actual_value) or actual_value
|
||||
|
||||
return op(final_actual_value, numeric_comparison if None is not numeric_comparison else comparison_value)
|
||||
|
||||
# unary operators
|
||||
operator_rex = re.compile(r"(?P<op>{})\s*(?P<key>[a-z_]+)".format("|".join(self.UNARY_OPERATORS)))
|
||||
if m := operator_rex.fullmatch(filter_part.strip()):
|
||||
op: Any = self.UNARY_OPERATORS[m.group("op")]
|
||||
actual_value = dct.get(m.group("key"))
|
||||
return op(actual_value)
|
||||
|
||||
msg: str = f"Invalid filter part {filter_part!r}"
|
||||
raise ValueError(msg)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
# flake8: noqa: SIM115 S310 T201
|
||||
# flake8: noqa: S310 T201
|
||||
|
||||
import argparse
|
||||
import os
|
||||
|
|
|
|||
15
app/main.py
15
app/main.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
481
app/tests/mini_filter_test.py
Normal file
481
app/tests/mini_filter_test.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import unittest
|
||||
from typing import Any
|
||||
|
||||
from app.library.mini_filter import MiniFilter
|
||||
|
||||
|
||||
class TestMiniFilter(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _normalize(filters) -> set[str]:
|
||||
"""
|
||||
Normalize AND order so 'a&b' == 'b&a'
|
||||
|
||||
Args:
|
||||
filters (list[str]): List of filter strings
|
||||
|
||||
Returns:
|
||||
set[str]: Set of normalized filter strings
|
||||
|
||||
"""
|
||||
return {"&".join(sorted(p.split("&"))) for p in filters}
|
||||
|
||||
def _test(
|
||||
self, expr: str, test_data: dict, *, expected_result: bool, test_name: str = "", skip_ytdlp: bool = False
|
||||
):
|
||||
"""
|
||||
Test both our implementation and yt-dlp's implementation to ensure they give the same result.
|
||||
|
||||
This method:
|
||||
1. Tests our implementation directly on the full expression (supports OR, grouping)
|
||||
2. Exports our expression to yt-dlp compatible format (flattens OR/grouping to multiple AND-only filters)
|
||||
3. Tests yt-dlp on each exported filter (OR logic: any filter match = True)
|
||||
4. Verifies both implementations agree
|
||||
|
||||
Args:
|
||||
expr: Filter expression string (may contain OR, parentheses)
|
||||
test_data: Dictionary to test against
|
||||
expected_result: Expected boolean result (keyword-only)
|
||||
test_name: Optional test name for better error messages
|
||||
skip_ytdlp: Skip yt-dlp comparison due to known bugs (keyword-only)
|
||||
|
||||
"""
|
||||
from yt_dlp.utils import match_str
|
||||
|
||||
# Step 1: Test our implementation directly
|
||||
parser = MiniFilter(expr)
|
||||
our_result = parser.evaluate(test_data)
|
||||
|
||||
# Step 2: Export to yt-dlp compatible format
|
||||
# Our export() method flattens OR/grouping into multiple AND-only strings
|
||||
exported_filters: list[str] = parser.export()
|
||||
|
||||
# Step 3: Test yt-dlp implementation (unless skipped)
|
||||
if not skip_ytdlp:
|
||||
# Since yt-dlp doesn't support OR, we test each exported filter separately
|
||||
# The overall result is True if ANY exported filter matches (OR logic)
|
||||
ytdlp_result = any(match_str(filter_part, test_data) for filter_part in exported_filters)
|
||||
|
||||
# Step 4b: Verify yt-dlp gives the expected result
|
||||
assert ytdlp_result == expected_result, (
|
||||
f"{test_name}: yt-dlp impl failed - expr: '{expr}' -> {exported_filters}, expected: {expected_result}, got: {ytdlp_result}"
|
||||
)
|
||||
|
||||
# Step 5: Verify both implementations agree with each other
|
||||
assert our_result == ytdlp_result, (
|
||||
f"{test_name}: Implementations disagree - expr: '{expr}', our: {our_result}, yt-dlp: {ytdlp_result}, "
|
||||
f"exported: {exported_filters}"
|
||||
)
|
||||
|
||||
# Step 4a: Verify our implementation gives the expected result
|
||||
assert our_result == expected_result, (
|
||||
f"{test_name}: Our impl failed - expr: '{expr}', expected: {expected_result}, got: {our_result}"
|
||||
)
|
||||
|
||||
return our_result
|
||||
|
||||
def test_simple_and(self):
|
||||
expr = "filesize>1MB & duration<10m"
|
||||
parser = MiniFilter(expr)
|
||||
assert TestMiniFilter._normalize(parser.export()) == TestMiniFilter._normalize(["filesize>1MB&duration<10m"]), (
|
||||
"Failed export check"
|
||||
)
|
||||
|
||||
self._test(expr, {"filesize": 2000000, "duration": 200}, expected_result=True, test_name="simple_and_positive")
|
||||
self._test(expr, {"filesize": 500000, "duration": 200}, expected_result=False, test_name="simple_and_negative")
|
||||
|
||||
def test_or(self):
|
||||
expr = "uploader='BBC' || uploader='NHK'"
|
||||
parser = MiniFilter(expr)
|
||||
assert TestMiniFilter._normalize(parser.export()) == TestMiniFilter._normalize(
|
||||
["uploader='BBC'", "uploader='NHK'"]
|
||||
), "Failed export check"
|
||||
|
||||
self._test(expr, {"uploader": "BBC"}, expected_result=True, test_name="or_bbc")
|
||||
self._test(expr, {"uploader": "NHK"}, expected_result=True, test_name="or_nhk")
|
||||
self._test(expr, {"uploader": "CNN"}, expected_result=False, test_name="or_cnn")
|
||||
|
||||
def test_grouping(self):
|
||||
expr = "(filesize>1MB & duration<10m) || uploader='BBC'"
|
||||
parser = MiniFilter(expr)
|
||||
assert TestMiniFilter._normalize(parser.export()) == TestMiniFilter._normalize(
|
||||
["filesize>1MB&duration<10m", "uploader='BBC'"]
|
||||
), "Failed export check"
|
||||
|
||||
self._test(
|
||||
expr, {"filesize": 2000000, "duration": 200}, expected_result=True, test_name="grouping_filesize_match"
|
||||
)
|
||||
self._test(expr, {"uploader": "BBC"}, expected_result=True, test_name="grouping_uploader_match")
|
||||
self._test(
|
||||
expr,
|
||||
{"filesize": 500000, "duration": 200, "uploader": "CNN"},
|
||||
expected_result=False,
|
||||
test_name="grouping_no_match",
|
||||
)
|
||||
|
||||
def test_unary_presence(self):
|
||||
# Test duration presence
|
||||
self._test("duration", {"duration": 100}, expected_result=True, test_name="unary_duration_present")
|
||||
self._test("duration", {}, expected_result=False, test_name="unary_duration_absent")
|
||||
|
||||
# Test duration absence
|
||||
self._test("!duration", {}, expected_result=True, test_name="unary_not_duration_absent")
|
||||
self._test("!duration", {"duration": 100}, expected_result=False, test_name="unary_not_duration_present")
|
||||
|
||||
def test_duration_units(self):
|
||||
# Test with numeric duration values to avoid yt-dlp's inconsistent unit parsing
|
||||
# Using 120 seconds = 2 minutes
|
||||
self._test("duration<120", {"duration": 30}, expected_result=True, test_name="duration_120_positive")
|
||||
self._test("duration<120", {"duration": 200}, expected_result=False, test_name="duration_120_negative")
|
||||
|
||||
# Test 90 seconds
|
||||
self._test("duration<90", {"duration": 30}, expected_result=True, test_name="duration_90_positive")
|
||||
self._test("duration<90", {"duration": 120}, expected_result=False, test_name="duration_90_negative")
|
||||
|
||||
# Test 3600 seconds = 1 hour
|
||||
self._test("duration<3600", {"duration": 3599}, expected_result=True, test_name="duration_3600_positive")
|
||||
self._test("duration<3600", {"duration": 3700}, expected_result=False, test_name="duration_3600_negative")
|
||||
|
||||
def test_duration_units_with_suffixes(self):
|
||||
"""Test duration comparisons with time unit suffixes (m, h). Skip yt-dlp due to known parsing bugs."""
|
||||
# Test 2 minutes (120 seconds)
|
||||
self._test(
|
||||
"duration<2m", {"duration": 90}, expected_result=True, test_name="duration_2m_positive", skip_ytdlp=True
|
||||
)
|
||||
self._test(
|
||||
"duration<2m", {"duration": 150}, expected_result=False, test_name="duration_2m_negative", skip_ytdlp=True
|
||||
)
|
||||
|
||||
# Test 5 minutes (300 seconds)
|
||||
self._test(
|
||||
"duration<5m", {"duration": 240}, expected_result=True, test_name="duration_5m_positive", skip_ytdlp=True
|
||||
)
|
||||
self._test(
|
||||
"duration<5m", {"duration": 360}, expected_result=False, test_name="duration_5m_negative", skip_ytdlp=True
|
||||
)
|
||||
|
||||
# Test 10 minutes (600 seconds)
|
||||
self._test(
|
||||
"duration<10m", {"duration": 480}, expected_result=True, test_name="duration_10m_positive", skip_ytdlp=True
|
||||
)
|
||||
self._test(
|
||||
"duration<10m", {"duration": 720}, expected_result=False, test_name="duration_10m_negative", skip_ytdlp=True
|
||||
)
|
||||
|
||||
# Test 1 hour (3600 seconds)
|
||||
self._test(
|
||||
"duration<1h", {"duration": 3000}, expected_result=True, test_name="duration_1h_positive", skip_ytdlp=True
|
||||
)
|
||||
self._test(
|
||||
"duration<1h", {"duration": 4000}, expected_result=False, test_name="duration_1h_negative", skip_ytdlp=True
|
||||
)
|
||||
|
||||
# Test >= operator with minutes
|
||||
self._test(
|
||||
"duration>=5m", {"duration": 300}, expected_result=True, test_name="duration_gte_5m_equal", skip_ytdlp=True
|
||||
)
|
||||
self._test(
|
||||
"duration>=5m",
|
||||
{"duration": 400},
|
||||
expected_result=True,
|
||||
test_name="duration_gte_5m_greater",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
self._test(
|
||||
"duration>=5m", {"duration": 200}, expected_result=False, test_name="duration_gte_5m_less", skip_ytdlp=True
|
||||
)
|
||||
|
||||
def test_filesize_units(self):
|
||||
# Test 1MB
|
||||
self._test("filesize>1MB", {"filesize": 2000000}, expected_result=True, test_name="filesize_1mb_positive")
|
||||
self._test("filesize>1MB", {"filesize": 500000}, expected_result=False, test_name="filesize_1mb_negative")
|
||||
|
||||
# Test 1GiB
|
||||
self._test("filesize>=1GiB", {"filesize": 2**30}, expected_result=True, test_name="filesize_1gib_positive")
|
||||
self._test("filesize>=1GiB", {"filesize": 1000000}, expected_result=False, test_name="filesize_1gib_negative")
|
||||
|
||||
def test_complex_duration_units_with_or_and_grouping(self):
|
||||
"""Test complex expressions with duration units, OR operations, and grouping. Skip yt-dlp due to known parsing bugs."""
|
||||
# Test grouping with duration units
|
||||
expr = "(filesize>1MB & duration<10m) || uploader='BBC'"
|
||||
self._test(
|
||||
expr,
|
||||
{"filesize": 2000000, "duration": 300},
|
||||
expected_result=True,
|
||||
test_name="complex_duration_filesize_match",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
self._test(
|
||||
expr,
|
||||
{"uploader": "BBC"},
|
||||
expected_result=True,
|
||||
test_name="complex_duration_uploader_match",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
self._test(
|
||||
expr,
|
||||
{"filesize": 500000, "duration": 300, "uploader": "CNN"},
|
||||
expected_result=False,
|
||||
test_name="complex_duration_no_match",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
|
||||
# Test OR with different duration units
|
||||
expr = "duration<2m || duration>1h"
|
||||
self._test(expr, {"duration": 60}, expected_result=True, test_name="complex_or_duration_short", skip_ytdlp=True)
|
||||
self._test(
|
||||
expr, {"duration": 4000}, expected_result=True, test_name="complex_or_duration_long", skip_ytdlp=True
|
||||
)
|
||||
self._test(
|
||||
expr, {"duration": 1800}, expected_result=False, test_name="complex_or_duration_middle", skip_ytdlp=True
|
||||
)
|
||||
|
||||
# Test complex expression with multiple duration conditions
|
||||
expr = "(duration>30s & duration<5m) || (duration>1h & uploader*='BBC')"
|
||||
self._test(
|
||||
expr,
|
||||
{"duration": 120},
|
||||
expected_result=True,
|
||||
test_name="complex_multi_duration_short_range",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
self._test(
|
||||
expr,
|
||||
{"duration": 4000, "uploader": "BBC News"},
|
||||
expected_result=True,
|
||||
test_name="complex_multi_duration_long_bbc",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
self._test(
|
||||
expr,
|
||||
{"duration": 4000, "uploader": "CNN"},
|
||||
expected_result=False,
|
||||
test_name="complex_multi_duration_long_cnn",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
self._test(
|
||||
expr,
|
||||
{"duration": 1800},
|
||||
expected_result=False,
|
||||
test_name="complex_multi_duration_middle_range",
|
||||
skip_ytdlp=True,
|
||||
)
|
||||
|
||||
def test_string_operators(self):
|
||||
d: dict[str, str] = {"uploader": "BBC News Channel"}
|
||||
|
||||
# Test all string operators with both implementations
|
||||
self._test("uploader*='News'", d, expected_result=True, test_name="string_contains")
|
||||
self._test("uploader^='BBC'", d, expected_result=True, test_name="string_startswith")
|
||||
self._test("uploader$='Channel'", d, expected_result=True, test_name="string_endswith")
|
||||
self._test("uploader~='News\\s+Channel'", d, expected_result=True, test_name="string_regex")
|
||||
|
||||
# Test negative cases
|
||||
self._test("uploader*='CNN'", d, expected_result=False, test_name="string_contains_negative")
|
||||
self._test("uploader^='CNN'", d, expected_result=False, test_name="string_startswith_negative")
|
||||
self._test("uploader$='BBC'", d, expected_result=False, test_name="string_endswith_negative")
|
||||
|
||||
def test_spaces_around_operators(self):
|
||||
"""Test that spaces around operators are handled correctly."""
|
||||
d: dict[str, str] = {"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w", "uploader": "BBC"}
|
||||
|
||||
# Test with spaces around equals
|
||||
self._test("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'", d, expected_result=True, test_name="spaced_equals_match")
|
||||
self._test("channel_id = 'different-id'", d, expected_result=False, test_name="spaced_equals_non_match")
|
||||
|
||||
# Test with spaces in complex expressions
|
||||
self._test(
|
||||
"channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w' & uploader = 'BBC'",
|
||||
d,
|
||||
expected_result=True,
|
||||
test_name="complex_spaced_expression",
|
||||
)
|
||||
self._test(
|
||||
"channel_id = 'different-id' & uploader = 'BBC'",
|
||||
d,
|
||||
expected_result=False,
|
||||
test_name="complex_spaced_non_match",
|
||||
)
|
||||
|
||||
# Test various operators with spaces
|
||||
d_numeric: dict[str, int] = {"filesize": 2000000, "duration": 200}
|
||||
self._test("filesize > 1000000", d_numeric, expected_result=True, test_name="spaced_greater_than")
|
||||
self._test("filesize >= 2000000", d_numeric, expected_result=True, test_name="spaced_greater_equal")
|
||||
self._test("duration < 300", d_numeric, expected_result=True, test_name="spaced_less_than")
|
||||
self._test("duration <= 200", d_numeric, expected_result=True, test_name="spaced_less_equal")
|
||||
|
||||
def test_original_bug_reproduction(self):
|
||||
"""Test the exact case from the original bug report."""
|
||||
# Exact data from the original bug report
|
||||
test_data: dict[str, Any] = {
|
||||
"age_limit": 0,
|
||||
"comment_count": 6,
|
||||
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
|
||||
"uploader_url": "https://www.youtube.com/@PlayFramePlus",
|
||||
}
|
||||
|
||||
# This filter should return FALSE because:
|
||||
# 1. channel_id doesn't match (UC-7oMv6E4Uz2tF51w5Sj49w vs UCfmrcEdes7yDtEISGPM1T-A)
|
||||
# 2. availability key doesn't exist in the data
|
||||
filter_expr = "channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only"
|
||||
self._test(filter_expr, test_data, expected_result=False, test_name="original_bug_full")
|
||||
|
||||
# Individual parts should also be false
|
||||
self._test(
|
||||
"channel_id = 'UCfmrcEdes7yDtEISGPM1T-A'",
|
||||
test_data,
|
||||
expected_result=False,
|
||||
test_name="original_bug_wrong_channel",
|
||||
)
|
||||
self._test(
|
||||
"availability = subscriber_only", test_data, expected_result=False, test_name="original_bug_missing_key"
|
||||
)
|
||||
|
||||
# But the correct channel_id should match
|
||||
self._test(
|
||||
"channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'",
|
||||
test_data,
|
||||
expected_result=True,
|
||||
test_name="original_bug_correct_channel",
|
||||
)
|
||||
|
||||
def test_or_operator_precedence(self):
|
||||
"""Test operator precedence and grouping with OR statements."""
|
||||
# Test data for the examples
|
||||
test_data: dict[str, int] = {
|
||||
"age_limit": 0,
|
||||
"fps": 120,
|
||||
"like_count": 81,
|
||||
}
|
||||
|
||||
# Case 1: (age_limit=0 & fps=120) || like_count=81
|
||||
# This should evaluate as: (True & True) || True = True || True = True
|
||||
expr1 = "(age_limit=0 & fps=120) || like_count=81"
|
||||
self._test(expr1, test_data, expected_result=True, test_name="or_precedence_case1")
|
||||
|
||||
# Case 2: age_limit=0 & fps=120 || like_count=81
|
||||
# This evaluates left-to-right as: (age_limit=0 & fps=120) || like_count=81
|
||||
# = (True & True) || True = True || True = True
|
||||
expr2 = "age_limit=0 & fps=120 || like_count=81"
|
||||
self._test(expr2, test_data, expected_result=True, test_name="or_precedence_case2")
|
||||
|
||||
# Test with data that shows left-to-right evaluation
|
||||
test_data_partial: dict[str, int] = {
|
||||
"age_limit": 0,
|
||||
"fps": 60, # Changed from 120 to make first AND false
|
||||
"like_count": 81,
|
||||
}
|
||||
|
||||
# Case 1 with partial data: (age_limit=0 & fps=120) || like_count=81
|
||||
# This should be: (True & False) || True = False || True = True
|
||||
self._test(expr1, test_data_partial, expected_result=True, test_name="or_precedence_case1_partial")
|
||||
|
||||
# Case 2 with partial data: age_limit=0 & fps=120 || like_count=81
|
||||
# This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (True & False) || True = False || True = True
|
||||
self._test(expr2, test_data_partial, expected_result=True, test_name="or_precedence_case2_partial")
|
||||
|
||||
# Test case where the difference becomes clear
|
||||
test_data_edge: dict[str, int] = {
|
||||
"age_limit": 1, # Changed from 0 to make age_limit=0 false
|
||||
"fps": 60, # Changed from 120 to make fps=120 false
|
||||
"like_count": 81,
|
||||
}
|
||||
|
||||
# Case 1 with edge data: (age_limit=0 & fps=120) || like_count=81
|
||||
# This should be: (False & False) || True = False || True = True
|
||||
self._test(expr1, test_data_edge, expected_result=True, test_name="or_precedence_case1_edge")
|
||||
|
||||
# Case 2 with edge data: age_limit=0 & fps=120 || like_count=81
|
||||
# This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (False & False) || True = False || True = True
|
||||
self._test(expr2, test_data_edge, expected_result=True, test_name="or_precedence_case2_edge")
|
||||
|
||||
def test_complex_or_precedence_scenarios(self):
|
||||
"""Test more complex OR precedence scenarios."""
|
||||
# Test case where only the OR part is true
|
||||
test_data_or_only: dict[str, int] = {
|
||||
"age_limit": 1, # False
|
||||
"fps": 60, # False (not 120)
|
||||
"like_count": 81, # True
|
||||
}
|
||||
|
||||
# Expression: a & b || c
|
||||
# Evaluates left-to-right as: (a & b) || c = (False & False) || True = False || True = True
|
||||
expr = "age_limit=0 & fps=120 || like_count=81"
|
||||
self._test(expr, test_data_or_only, expected_result=True, test_name="complex_or_only_or_true")
|
||||
|
||||
# Test case where only the AND part is true
|
||||
test_data_and_only: dict[str, int] = {
|
||||
"age_limit": 0, # True
|
||||
"fps": 120, # True
|
||||
"like_count": 50, # False (not 81)
|
||||
}
|
||||
|
||||
# Expression: a & b || c
|
||||
# Evaluates as: (a & b) || c = (True & True) || False = True || False = True
|
||||
self._test(expr, test_data_and_only, expected_result=True, test_name="complex_or_only_and_true")
|
||||
|
||||
# Test case where everything is false except the OR part
|
||||
test_data_only_or: dict[str, int] = {
|
||||
"age_limit": 1, # False
|
||||
"fps": 60, # False
|
||||
"like_count": 50, # False
|
||||
"view_count": 1000, # True
|
||||
}
|
||||
|
||||
# Test chained OR with AND: a & b || c || d
|
||||
# Evaluates left-to-right as: ((a & b) || c) || d
|
||||
expr_chain = "age_limit=0 & fps=120 || like_count=81 || view_count=1000"
|
||||
self._test(expr_chain, test_data_only_or, expected_result=True, test_name="complex_or_chained")
|
||||
|
||||
def test_comprehensive_comparison(self):
|
||||
"""
|
||||
Comprehensive test to ensure our implementation and yt-dlp's always agree
|
||||
on a variety of test cases.
|
||||
"""
|
||||
test_cases = [
|
||||
# Simple cases - use numeric values to avoid yt-dlp unit parsing inconsistencies
|
||||
("filesize>1000000", {"filesize": 2000000}, True), # 1MB = 1000000 bytes
|
||||
("filesize>1000000", {"filesize": 500000}, False),
|
||||
("duration<600", {"duration": 200}, True), # 10min = 600 seconds
|
||||
("duration<600", {"duration": 800}, False),
|
||||
# String operations
|
||||
("uploader*='BBC'", {"uploader": "BBC News"}, True),
|
||||
("uploader*='BBC'", {"uploader": "CNN News"}, False),
|
||||
("title^='How'", {"title": "How to code"}, True),
|
||||
("title^='How'", {"title": "Learn how"}, False),
|
||||
# AND operations - use numeric values
|
||||
("filesize>1000000 & duration<600", {"filesize": 2000000, "duration": 200}, True),
|
||||
("filesize>1000000 & duration<600", {"filesize": 500000, "duration": 200}, False),
|
||||
("filesize>1000000 & duration<600", {"filesize": 2000000, "duration": 800}, False),
|
||||
# Unary operations
|
||||
("duration", {"duration": 100}, True),
|
||||
("duration", {}, False),
|
||||
("!duration", {}, True),
|
||||
("!duration", {"duration": 100}, False),
|
||||
# Complex expressions with parentheses and OR - use numeric values
|
||||
("(filesize>1000000 & duration<600) || uploader*='BBC'", {"filesize": 2000000, "duration": 200}, True),
|
||||
("(filesize>1000000 & duration<600) || uploader*='BBC'", {"uploader": "BBC News"}, True),
|
||||
("(filesize>1000000 & duration<600) || uploader*='BBC'", {"filesize": 500000, "uploader": "CNN"}, False),
|
||||
# Edge cases with missing fields
|
||||
("missing_field=test", {"other_field": "value"}, False),
|
||||
("!missing_field", {"other_field": "value"}, True),
|
||||
]
|
||||
|
||||
for expr, test_data, expected in test_cases:
|
||||
test_name = f"comprehensive_{expr.replace(' ', '_').replace('>', 'gt').replace('<', 'lt').replace('=', 'eq').replace('&', 'and').replace('||', 'or').replace('(', '').replace(')', '').replace('*', 'contains').replace('^', 'starts').replace('!', 'not')}"
|
||||
self._test(expr, test_data, expected_result=expected, test_name=test_name)
|
||||
|
||||
def test_export_and_yt_dlp_compat(self):
|
||||
from yt_dlp.utils import match_str
|
||||
|
||||
d: dict[str, str] = {"filesize": 2000000, "duration": 200, "uploader": "BBC"}
|
||||
# Use numeric values to avoid yt-dlp unit parsing inconsistencies
|
||||
expr = "(filesize>1000000 & duration<600) || uploader='BBC'"
|
||||
|
||||
parser = MiniFilter(expr)
|
||||
|
||||
for part in parser.export():
|
||||
assert match_str(part, d), f"Failed to match {part} with {d}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
502
app/tests/test_ag_utils.py
Normal file
502
app/tests/test_ag_utils.py
Normal 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
260
app/tests/test_cache.py
Normal 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__])
|
||||
751
app/tests/test_conditions.py
Normal file
751
app/tests/test_conditions.py
Normal 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
635
app/tests/test_dl_fields.py
Normal 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
170
app/tests/test_encoder.py
Normal 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
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
255
app/tests/test_singleton.py
Normal 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
1500
app/tests/test_utils.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -79,9 +79,7 @@ indent-width = 4
|
|||
target-version = "py311"
|
||||
|
||||
[project.optional-dependencies]
|
||||
installer = [
|
||||
"pyinstaller",
|
||||
]
|
||||
installer = ["pyinstaller"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ const validateItem = (item: DLField, index: number): boolean => {
|
|||
return false
|
||||
}
|
||||
|
||||
if (!/^--[a-zA-Z0-9\-]+$/.test(item.field)) {
|
||||
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
|
||||
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
|
||||
return false
|
||||
}
|
||||
|
|
@ -278,6 +278,7 @@ const validateItem = (item: DLField, index: number): boolean => {
|
|||
return true
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ const defaultTitle = computed(() => {
|
|||
return 'Confirm'
|
||||
case 'prompt':
|
||||
return 'Input required'
|
||||
default:
|
||||
return 'Dialog'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<vTooltip @show="loadContent" @hide="stopTimer">
|
||||
<slot />
|
||||
<template #popper class="p-0 m-0">
|
||||
<template #popper>
|
||||
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
||||
<template v-else>
|
||||
<div v-else>
|
||||
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
||||
<div class="is-block" style="word-break: all;" v-if="props.title">
|
||||
<span style="font-size: 120%;">{{ props.title }}</span>
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</vTooltip>
|
||||
</template>
|
||||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@
|
|||
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
||||
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
||||
{{ item.name }}
|
||||
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
|
||||
{{ cPreset.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Default presets">
|
||||
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
||||
{{ item.name }}
|
||||
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
|
||||
{{ dPreset.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
|
@ -127,7 +127,7 @@
|
|||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are supported
|
||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are supported
|
||||
<NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
|
|
@ -238,7 +238,6 @@ const props = defineProps<{ item?: Partial<item_request> }>()
|
|||
const emitter = defineEmits<{
|
||||
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
|
||||
(e: 'clear_form'): void
|
||||
(e: 'remove_archive', url: string): void
|
||||
}>()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
|
|
@ -303,7 +302,7 @@ const addDownload = async () => {
|
|||
continue
|
||||
}
|
||||
|
||||
const keyRegex = new RegExp(`(^|\s)${key}(\s|$)`);
|
||||
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
|
||||
if (form_cli && keyRegex.test(form_cli)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -514,5 +513,6 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
|
|||
return ret
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -100,8 +100,8 @@
|
|||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
|
||||
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item">
|
||||
{{ item }}
|
||||
<option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod">
|
||||
{{ rMethod }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -125,8 +125,8 @@
|
|||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
|
||||
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item">
|
||||
{{ ucFirst(item) }}
|
||||
<option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType">
|
||||
{{ ucFirst(rType) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -153,8 +153,8 @@
|
|||
<div class="control has-icons-left">
|
||||
<div class="select is-multiple is-fullwidth">
|
||||
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
|
||||
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item">
|
||||
{{ item }}
|
||||
<option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent">
|
||||
{{ aEvent }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
|
@ -183,13 +183,13 @@
|
|||
<div class="select is-multiple is-fullwidth">
|
||||
<select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple>
|
||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||
<option v-for="item in filter_presets(false)" :key="item.id" :value="item.name">
|
||||
{{ item.name }}
|
||||
<option v-for="cPreset in filter_presets(false)" :key="cPreset.id" :value="cPreset.name">
|
||||
{{ cPreset.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Default presets">
|
||||
<option v-for="item in filter_presets(true)" :key="item.id" :value="item.name">
|
||||
{{ item.name }}
|
||||
<option v-for="dPreset in filter_presets(true)" :key="dPreset.id" :value="dPreset.name">
|
||||
{{ dPreset.name }}
|
||||
</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
|
@ -377,7 +377,7 @@ const checkInfo = async () => {
|
|||
if (!isApprise.value) {
|
||||
try {
|
||||
new URL(form.request.url)
|
||||
} catch (_) {
|
||||
} catch {
|
||||
toast.error('Invalid URL')
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ import { useStorage } from '@vueuse/core'
|
|||
import { POSITION } from 'vue-toastification'
|
||||
|
||||
defineProps<{ isLoading: boolean }>()
|
||||
defineEmits<{ (e: 'reload_bg'): void }>()
|
||||
|
||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@
|
|||
<span class="help is-bold">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
||||
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
||||
supported <NuxtLink target="_blank"
|
||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||
are ignored</NuxtLink>. Use with caution.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@
|
|||
|
||||
<NewDownload v-if="config.showForm || config.app.basic_mode"
|
||||
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||
:item="item_form" @clear_form="item_form = {}" @remove_archive="" />
|
||||
:item="item_form" @clear_form="item_form = {}" />
|
||||
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||
@clear_search="query = ''" />
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@
|
|||
<td class="is-text-overflow is-vcentered">
|
||||
<div class="is-bold">
|
||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||
</div>
|
||||
<div class="is-unselectable">
|
||||
<span class="icon-text">
|
||||
|
|
@ -150,7 +150,7 @@
|
|||
</p>
|
||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
|
||||
<span>{{ item.request.headers.map(h => h.key).join(', ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
2
ui/app/types/dl_fields.d.ts
vendored
2
ui/app/types/dl_fields.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
12
ui/app/types/sockets.d.ts
vendored
12
ui/app/types/sockets.d.ts
vendored
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
238
ui/app/utils/ytdlp.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { MatchFilterParser } from './ytdlp'
|
||||
|
||||
function normalize(filters: string[]): Set<string> {
|
||||
return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));
|
||||
}
|
||||
|
||||
describe("MatchFilterParser", () => {
|
||||
it("handles simple AND", () => {
|
||||
const parser = new MatchFilterParser("filesize>1000000 & duration<600");
|
||||
|
||||
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
|
||||
expect(parser.evaluate({ filesize: 500_000, duration: 200 })).toBe(false);
|
||||
expect(parser.evaluate({ filesize: 2_000_000, duration: 800 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles OR", () => {
|
||||
const parser = new MatchFilterParser("uploader='BBC' || uploader='NHK'");
|
||||
|
||||
expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
|
||||
expect(parser.evaluate({ uploader: "NHK" })).toBe(true);
|
||||
expect(parser.evaluate({ uploader: "CNN" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles grouping", () => {
|
||||
const parser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
|
||||
|
||||
expect(parser.evaluate({ filesize: 2_000_000, duration: 200 })).toBe(true);
|
||||
expect(parser.evaluate({ uploader: "BBC" })).toBe(true);
|
||||
expect(parser.evaluate({ filesize: 500_000, duration: 200, uploader: "CNN" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles unary presence", () => {
|
||||
expect(new MatchFilterParser("duration").evaluate({ duration: 100 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration").evaluate({})).toBe(false);
|
||||
expect(new MatchFilterParser("!duration").evaluate({})).toBe(true);
|
||||
expect(new MatchFilterParser("!duration").evaluate({ duration: 100 })).toBe(false);
|
||||
});
|
||||
|
||||
it("parses duration with numeric values", () => {
|
||||
// Use numeric seconds instead of unit formats for consistency
|
||||
// 120 seconds = 2 minutes
|
||||
expect(new MatchFilterParser("duration<120").evaluate({ duration: 30 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration<120").evaluate({ duration: 200 })).toBe(false);
|
||||
|
||||
// 90 seconds
|
||||
expect(new MatchFilterParser("duration<90").evaluate({ duration: 30 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration<90").evaluate({ duration: 120 })).toBe(false);
|
||||
|
||||
// 3600 seconds = 1 hour
|
||||
expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3599 })).toBe(true);
|
||||
expect(new MatchFilterParser("duration<3600").evaluate({ duration: 3700 })).toBe(false);
|
||||
});
|
||||
|
||||
it("parses filesize with numeric values", () => {
|
||||
// Use numeric bytes instead of unit formats for consistency
|
||||
// 1MB = 1,000,000 bytes
|
||||
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 2_000_000 })).toBe(true);
|
||||
expect(new MatchFilterParser("filesize>1000000").evaluate({ filesize: 500_000 })).toBe(false);
|
||||
|
||||
// 1GiB = 1,073,741,824 bytes
|
||||
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 2 ** 30 })).toBe(true);
|
||||
expect(new MatchFilterParser("filesize>=1073741824").evaluate({ filesize: 1000000 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles string operators", () => {
|
||||
const d = { uploader: "BBC News Channel" };
|
||||
|
||||
// Test all string operators with positive cases
|
||||
expect(new MatchFilterParser("uploader*='News'").evaluate(d)).toBe(true); // contains
|
||||
expect(new MatchFilterParser("uploader^='BBC'").evaluate(d)).toBe(true); // starts with
|
||||
expect(new MatchFilterParser("uploader$='Channel'").evaluate(d)).toBe(true); // ends with
|
||||
expect(new MatchFilterParser("uploader~='News\\s+Channel'").evaluate(d)).toBe(true); // regex
|
||||
|
||||
// Test negative cases
|
||||
expect(new MatchFilterParser("uploader*='CNN'").evaluate(d)).toBe(false); // contains
|
||||
expect(new MatchFilterParser("uploader^='CNN'").evaluate(d)).toBe(false); // starts with
|
||||
expect(new MatchFilterParser("uploader$='BBC'").evaluate(d)).toBe(false); // ends with
|
||||
expect(new MatchFilterParser("uploader~='News\\s+Network'").evaluate(d)).toBe(false); // regex
|
||||
});
|
||||
|
||||
it("handles spaces around operators", () => {
|
||||
const d = {
|
||||
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
|
||||
"uploader": "BBC"
|
||||
};
|
||||
|
||||
// Test with spaces around equals
|
||||
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(d)).toBe(true);
|
||||
expect(new MatchFilterParser("channel_id = 'different-id'").evaluate(d)).toBe(false);
|
||||
|
||||
// Test with spaces in complex expressions
|
||||
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w' & uploader = 'BBC'").evaluate(d)).toBe(true);
|
||||
expect(new MatchFilterParser("channel_id = 'different-id' & uploader = 'BBC'").evaluate(d)).toBe(false);
|
||||
|
||||
// Test the specific failing case from the bug report
|
||||
expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only").evaluate(d)).toBe(false);
|
||||
});
|
||||
|
||||
it("reproduces original bug report", () => {
|
||||
// Exact data from the original bug report
|
||||
const testData = {
|
||||
"age_limit": 0,
|
||||
"comment_count": 6,
|
||||
"channel_id": "UC-7oMv6E4Uz2tF51w5Sj49w",
|
||||
"uploader_url": "https://www.youtube.com/@PlayFramePlus"
|
||||
};
|
||||
|
||||
// This filter should return FALSE because:
|
||||
// 1. channel_id doesn't match (UC-7oMv6E4Uz2tF51w5Sj49w vs UCfmrcEdes7yDtEISGPM1T-A)
|
||||
// 2. availability key doesn't exist in the data
|
||||
const filter = "channel_id = 'UCfmrcEdes7yDtEISGPM1T-A' & availability = subscriber_only";
|
||||
expect(new MatchFilterParser(filter).evaluate(testData)).toBe(false);
|
||||
|
||||
// Individual parts should also be false
|
||||
expect(new MatchFilterParser("channel_id = 'UCfmrcEdes7yDtEISGPM1T-A'").evaluate(testData)).toBe(false);
|
||||
expect(new MatchFilterParser("availability = subscriber_only").evaluate(testData)).toBe(false);
|
||||
|
||||
// But the correct channel_id should match
|
||||
expect(new MatchFilterParser("channel_id = 'UC-7oMv6E4Uz2tF51w5Sj49w'").evaluate(testData)).toBe(true);
|
||||
});
|
||||
|
||||
it("tests OR operator precedence", () => {
|
||||
// Test data for the examples
|
||||
const testData = {
|
||||
"age_limit": 0,
|
||||
"fps": 120,
|
||||
"like_count": 81,
|
||||
};
|
||||
|
||||
// Case 1: (age_limit=0 & fps=120) || like_count=81
|
||||
// This should evaluate as: (True & True) || True = True || True = True
|
||||
const expr1 = "(age_limit=0 & fps=120) || like_count=81";
|
||||
expect(new MatchFilterParser(expr1).evaluate(testData)).toBe(true);
|
||||
|
||||
// Case 2: age_limit=0 & fps=120 || like_count=81
|
||||
// This evaluates left-to-right as: (age_limit=0 & fps=120) || like_count=81
|
||||
// = (True & True) || True = True || True = True
|
||||
const expr2 = "age_limit=0 & fps=120 || like_count=81";
|
||||
expect(new MatchFilterParser(expr2).evaluate(testData)).toBe(true);
|
||||
|
||||
// Test with data that shows left-to-right evaluation
|
||||
const testDataPartial = {
|
||||
"age_limit": 0,
|
||||
"fps": 60, // Changed from 120 to make first AND false
|
||||
"like_count": 81,
|
||||
};
|
||||
|
||||
// Case 1 with partial data: (age_limit=0 & fps=120) || like_count=81
|
||||
// This should be: (True & False) || True = False || True = True
|
||||
expect(new MatchFilterParser(expr1).evaluate(testDataPartial)).toBe(true);
|
||||
|
||||
// Case 2 with partial data: age_limit=0 & fps=120 || like_count=81
|
||||
// This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (True & False) || True = False || True = True
|
||||
expect(new MatchFilterParser(expr2).evaluate(testDataPartial)).toBe(true);
|
||||
|
||||
// Test case where the difference becomes clear
|
||||
const testDataEdge = {
|
||||
"age_limit": 1, // Changed from 0 to make age_limit=0 false
|
||||
"fps": 60, // Changed from 120 to make fps=120 false
|
||||
"like_count": 81,
|
||||
};
|
||||
|
||||
// Case 1 with edge data: (age_limit=0 & fps=120) || like_count=81
|
||||
// This should be: (False & False) || True = False || True = True
|
||||
expect(new MatchFilterParser(expr1).evaluate(testDataEdge)).toBe(true);
|
||||
|
||||
// Case 2 with edge data: age_limit=0 & fps=120 || like_count=81
|
||||
// This evaluates as: (age_limit=0 & fps=120) || like_count=81 = (False & False) || True = False || True = True
|
||||
expect(new MatchFilterParser(expr2).evaluate(testDataEdge)).toBe(true);
|
||||
});
|
||||
|
||||
it("tests complex OR precedence scenarios", () => {
|
||||
// Test case where only the OR part is true
|
||||
const testDataOrOnly = {
|
||||
"age_limit": 1, // False
|
||||
"fps": 60, // False (not 120)
|
||||
"like_count": 81, // True
|
||||
};
|
||||
|
||||
// Expression: a & b || c
|
||||
// Evaluates left-to-right as: (a & b) || c = (False & False) || True = False || True = True
|
||||
const expr = "age_limit=0 & fps=120 || like_count=81";
|
||||
expect(new MatchFilterParser(expr).evaluate(testDataOrOnly)).toBe(true);
|
||||
|
||||
// Test case where only the AND part is true
|
||||
const testDataAndOnly = {
|
||||
"age_limit": 0, // True
|
||||
"fps": 120, // True
|
||||
"like_count": 50, // False (not 81)
|
||||
};
|
||||
|
||||
// Expression: a & b || c
|
||||
// Evaluates as: (a & b) || c = (True & True) || False = True || False = True
|
||||
expect(new MatchFilterParser(expr).evaluate(testDataAndOnly)).toBe(true);
|
||||
|
||||
// Test case where everything is false except the OR part
|
||||
const testDataOnlyOr = {
|
||||
"age_limit": 1, // False
|
||||
"fps": 60, // False
|
||||
"like_count": 50, // False
|
||||
"view_count": 1000, // True
|
||||
};
|
||||
|
||||
// Test chained OR with AND: a & b || c || d
|
||||
// Evaluates left-to-right as: ((a & b) || c) || d
|
||||
const exprChain = "age_limit=0 & fps=120 || like_count=81 || view_count=1000";
|
||||
expect(new MatchFilterParser(exprChain).evaluate(testDataOnlyOr)).toBe(true);
|
||||
});
|
||||
|
||||
it("exports filters correctly", () => {
|
||||
// Test simple AND expression
|
||||
const simpleParser = new MatchFilterParser("filesize>1000000 & duration<600");
|
||||
const simpleExported = simpleParser.export();
|
||||
expect(simpleExported).toEqual(["filesize>1000000&duration<600"]);
|
||||
|
||||
// Test OR expression (should split into multiple filters)
|
||||
const orParser = new MatchFilterParser("filesize>1000000 || uploader='BBC'");
|
||||
const orExported = orParser.export();
|
||||
expect(normalize(orExported)).toEqual(normalize(["filesize>1000000", "uploader='BBC'"]));
|
||||
|
||||
// Test complex expression with grouping
|
||||
const complexParser = new MatchFilterParser("(filesize>1000000 & duration<600) || uploader='BBC'");
|
||||
const complexExported = complexParser.export();
|
||||
expect(normalize(complexExported)).toEqual(normalize(["filesize>1000000&duration<600", "uploader='BBC'"]));
|
||||
|
||||
// Verify exported filters work individually
|
||||
const testData = { filesize: 2000000, duration: 300 };
|
||||
for (const filter of complexExported) {
|
||||
const filterParser = new MatchFilterParser(filter);
|
||||
// At least one of the exported filters should match
|
||||
if (filterParser.evaluate(testData)) {
|
||||
expect(true).toBe(true); // Success if any filter matches
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,176 +1,304 @@
|
|||
const NUMBER_RE = /\d+(?:\.\d+)?/;
|
||||
|
||||
function match_str(filterStr: string, dct: Record<string, any>, incomplete: boolean | Set<string> = false): boolean {
|
||||
if (!filterStr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\s*&\s*$/.test(filterStr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const filterParts = filterStr
|
||||
.split(/(?:(^|[^\\]))&/g)
|
||||
.reduce((parts, part, index, array) => {
|
||||
if (index % 2 === 0) {
|
||||
parts.push((part + (array[index + 1] || '')).replace(/\\&/g, '&'));
|
||||
}
|
||||
return parts;
|
||||
}, [] as string[]);
|
||||
|
||||
if (filterParts.some(fp => !fp.trim())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filterParts.every(filterPart => _match_one(filterPart, dct, incomplete));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null {
|
||||
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
|
||||
const unitsRe = Object.keys(unitTable).map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
||||
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? '' : 'i');
|
||||
const unitsRe = Object.keys(unitTable)
|
||||
.map(u => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("|");
|
||||
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? "" : "i");
|
||||
|
||||
const m = s.match(re);
|
||||
if (!m?.groups?.num || !m.groups.unit) {
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
const numStr = m.groups.num.replace(',', '.');
|
||||
const numStr = m.groups.num.replace(",", ".");
|
||||
const unit = m.groups.unit;
|
||||
const mult = unitTable[unit];
|
||||
if (undefined === mult) {
|
||||
return null;
|
||||
}
|
||||
if (undefined === mult) return null;
|
||||
|
||||
return Math.round(parseFloat(numStr) * mult);
|
||||
}
|
||||
|
||||
function parse_filesize(s: string | null): number | null {
|
||||
if (!s) return null;
|
||||
|
||||
const UNIT_TABLE: Record<string, number> = {
|
||||
B: 1, bytes: 1, b: 1,
|
||||
B: 1,
|
||||
b: 1,
|
||||
bytes: 1,
|
||||
KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024,
|
||||
MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2,
|
||||
GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3,
|
||||
TiB: 1024 ** 4, TB: 1000 ** 4, terabytes: 1000 ** 4, tebibytes: 1024 ** 4,
|
||||
PiB: 1024 ** 5, PB: 1000 ** 5, petabytes: 1000 ** 5, pebibytes: 1024 ** 5,
|
||||
};
|
||||
|
||||
return lookup_unit_table(UNIT_TABLE, s);
|
||||
}
|
||||
|
||||
function parse_duration(s: string): number | null {
|
||||
if (!s.trim()) {
|
||||
return null;
|
||||
if (!s?.trim()) return null;
|
||||
|
||||
// Support ISO-ish short forms: "2m", "10s", "1h", "1d"
|
||||
const simpleRe = /^(?<num>\d+(?:\.\d+)?)(?<unit>[smhd])$/i;
|
||||
const sm = s.match(simpleRe);
|
||||
if (sm?.groups?.num && sm.groups.unit) {
|
||||
const num = parseFloat(sm.groups.num);
|
||||
switch (sm.groups.unit.toLowerCase()) {
|
||||
case "s": return num;
|
||||
case "m": return num * 60;
|
||||
case "h": return num * 3600;
|
||||
case "d": return num * 86400;
|
||||
}
|
||||
}
|
||||
|
||||
const durationRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
|
||||
const m = s.match(durationRe);
|
||||
if (!m?.groups) {
|
||||
return null;
|
||||
// Support hh:mm:ss[.ms]
|
||||
const hmsRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
|
||||
const m = s.match(hmsRe);
|
||||
if (m?.groups) {
|
||||
const { days, hours, mins, secs, ms } = m.groups;
|
||||
return (
|
||||
(parseFloat(days ?? "0") * 86400) +
|
||||
(parseFloat(hours ?? "0") * 3600) +
|
||||
(parseFloat(mins ?? "0") * 60) +
|
||||
parseFloat(secs ?? "0") +
|
||||
parseFloat((ms ?? "0").replace(":", "."))
|
||||
);
|
||||
}
|
||||
|
||||
const { days, hours, mins, secs, ms } = m.groups;
|
||||
|
||||
return (
|
||||
(parseFloat(days ?? '0') * 86400) +
|
||||
(parseFloat(hours ?? '0') * 3600) +
|
||||
(parseFloat(mins ?? '0') * 60) + parseFloat(secs ?? '0') + parseFloat((ms ?? '0').replace(':', '.'))
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function _match_one(filterPart: string, dct: Record<string, any>, incomplete: boolean | Set<string>): boolean {
|
||||
|
||||
// ---------- Match One ----------
|
||||
function _match_one(filterPart: string, dct: Record<string, any>): boolean {
|
||||
const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = {
|
||||
'*=': (a, b) => a.includes(b),
|
||||
'^=': (a, b) => a.startsWith(b),
|
||||
'$=': (a, b) => a.endsWith(b),
|
||||
'~=': (a, b) => new RegExp(b).test(a),
|
||||
"*=": (a, b) => a.includes(b),
|
||||
"^=": (a, b) => a.startsWith(b),
|
||||
"$=": (a, b) => a.endsWith(b),
|
||||
"~=": (a, b) => new RegExp(b).test(a),
|
||||
};
|
||||
|
||||
const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
|
||||
'*=': STRING_OPERATORS['*=']!,
|
||||
'^=': STRING_OPERATORS['^=']!,
|
||||
'$=': STRING_OPERATORS['$=']!,
|
||||
'~=': STRING_OPERATORS['~=']!,
|
||||
'<=': (a, b) => a <= b,
|
||||
'<': (a, b) => a < b,
|
||||
'>=': (a, b) => a >= b,
|
||||
'>': (a, b) => a > b,
|
||||
'=': (a, b) => a == b,
|
||||
"*=": STRING_OPERATORS["*="]!,
|
||||
"^=": STRING_OPERATORS["^="]!,
|
||||
"$=": STRING_OPERATORS["$="]!,
|
||||
"~=": STRING_OPERATORS["~="]!,
|
||||
"<=": (a, b) => a <= b,
|
||||
"<": (a, b) => a < b,
|
||||
">=": (a, b) => a >= b,
|
||||
">": (a, b) => a > b,
|
||||
"=": (a, b) => a == b,
|
||||
};
|
||||
const UNARY_OPERATORS: Record<string, (v: any) => boolean> = {
|
||||
"": v => (typeof v === "boolean" ? v === true : v != null),
|
||||
"!": v => (typeof v === "boolean" ? v === false : v == null),
|
||||
};
|
||||
|
||||
const isIncomplete = typeof incomplete === 'boolean' ? (_: string) => incomplete : (k: string) => incomplete.has(k);
|
||||
const cmpOps = Object.keys(COMPARISON_OPERATORS)
|
||||
.map(op => op.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"))
|
||||
.join("|");
|
||||
|
||||
const cmpOps = Object.keys(COMPARISON_OPERATORS).map(op => op.replace(/([.*+?^${}()|\[\]\\])/g, '\\$1')).join('|');
|
||||
const operatorRe = new RegExp(`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+))$`);
|
||||
const operatorRe = new RegExp(
|
||||
`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+?))?$`
|
||||
);
|
||||
|
||||
const m = filterPart.trim().match(operatorRe);
|
||||
if (m?.groups) {
|
||||
const { key, negation, op, noneInclusive, quote, quoted, plain } = m.groups;
|
||||
const { key, negation, op, quote, quoted, plain } = m.groups;
|
||||
const unnegatedOp = COMPARISON_OPERATORS[op!];
|
||||
if (!unnegatedOp) {
|
||||
throw new Error(`Invalid operator '${op}'`);
|
||||
if (!unnegatedOp) throw new Error(`Invalid operator '${op}'`);
|
||||
|
||||
// Handle incomplete expressions (missing value)
|
||||
const value = quoted ?? plain ?? "";
|
||||
if (!value && op !== "=" && op !== "!") {
|
||||
return false; // Incomplete non-equality expression
|
||||
}
|
||||
|
||||
const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
|
||||
|
||||
let value = quoted ?? plain ?? '';
|
||||
|
||||
if (quote) {
|
||||
value = value.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
|
||||
}
|
||||
|
||||
if (!(key! in dct)) {
|
||||
return isIncomplete(key!) || Boolean(noneInclusive);
|
||||
}
|
||||
let processedValue = value;
|
||||
if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, "g"), quote);
|
||||
|
||||
const actual = dct[key!];
|
||||
let numeric: number | null = null;
|
||||
if (actual === undefined || actual === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof actual === 'number') {
|
||||
numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
numeric = parse_filesize(value) ?? parse_filesize(`${value}B`) ?? parse_duration(value);
|
||||
let numeric: number | null = null;
|
||||
// Try to convert comparison value to numeric first
|
||||
numeric = Number(processedValue);
|
||||
if (Number.isNaN(numeric)) {
|
||||
numeric = parse_filesize(processedValue) ?? parse_filesize(`${processedValue}B`) ?? parse_duration(processedValue);
|
||||
}
|
||||
|
||||
// Also try to convert actual value to numeric if we have a numeric comparison
|
||||
let finalActual = actual;
|
||||
if (numeric !== null && typeof actual === "string") {
|
||||
const numericActual = Number(actual);
|
||||
if (!Number.isNaN(numericActual)) {
|
||||
finalActual = numericActual;
|
||||
} else {
|
||||
const parsedActual = parse_filesize(actual) ?? parse_duration(actual);
|
||||
if (parsedActual !== null) {
|
||||
finalActual = parsedActual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (numeric !== null && op! in STRING_OPERATORS) {
|
||||
throw new Error(`Operator ${op} only supports string values!`);
|
||||
}
|
||||
|
||||
return opFn(actual, numeric !== null ? numeric : value);
|
||||
return opFn(finalActual, numeric !== null ? numeric : processedValue);
|
||||
}
|
||||
|
||||
const UNARY_OPERATORS: Record<string, (v: any) => boolean> = {
|
||||
'': v => typeof v === 'boolean' ? v === true : v != null,
|
||||
'!': v => typeof v === 'boolean' ? v === false : v == null,
|
||||
};
|
||||
|
||||
const unaryRe = new RegExp(`^(?<op>!?)\\s*(?<key>[a-z_]+)$`);
|
||||
// unary operator
|
||||
const unaryRe = /^(?<op>!?)\s*(?<key>[a-z_]+)$/;
|
||||
const mu = filterPart.trim().match(unaryRe);
|
||||
if (mu?.groups) {
|
||||
const { op, key } = mu.groups;
|
||||
|
||||
if (!(key! in dct)) {
|
||||
return isIncomplete(key!);
|
||||
}
|
||||
|
||||
const actual = dct[key!];
|
||||
const unaryOp = UNARY_OPERATORS[op!];
|
||||
|
||||
if (!unaryOp) {
|
||||
throw new Error(`Invalid unary operator '${op}'`);
|
||||
}
|
||||
|
||||
return unaryOp(actual);
|
||||
return UNARY_OPERATORS[op!]!(actual);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid filter part '${filterPart}'`);
|
||||
}
|
||||
|
||||
export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one }
|
||||
// ---------- Parser ----------
|
||||
type Node = ["ATOM", string] | ["AND", Node, Node] | ["OR", Node, Node];
|
||||
|
||||
class MatchFilterParser {
|
||||
expr: string;
|
||||
tokens: [string, string][];
|
||||
pos: number;
|
||||
ast: Node;
|
||||
|
||||
constructor(expr: string) {
|
||||
this.expr = expr;
|
||||
this.tokens = this._tokenize(expr);
|
||||
this.pos = 0;
|
||||
this.ast = this._parseOr();
|
||||
}
|
||||
|
||||
static run(expr: string, dct: Record<string, any> = {}): boolean {
|
||||
return new MatchFilterParser(expr).evaluate(dct);
|
||||
}
|
||||
|
||||
evaluate(dct: Record<string, any>): boolean {
|
||||
return this._eval(this.ast, dct);
|
||||
}
|
||||
|
||||
export(): string[] {
|
||||
return this._export(this.ast).map(parts => parts.join("&"));
|
||||
}
|
||||
|
||||
private _tokenize(expr: string): [string, string][] {
|
||||
// First, let's normalize spaces around operators to make parsing easier
|
||||
// Replace spaced operators with non-spaced ones
|
||||
let normalizedExpr = expr;
|
||||
const operators = ["<=", ">=", "<", ">", "\\*=", "\\^=", "\\$=", "~=", "="];
|
||||
for (const op of operators) {
|
||||
// Replace "key op value" with "keyopvalue" (removing extra spaces)
|
||||
const pattern = new RegExp(`([a-z_]+)\\s*(!?)\\s*(${op})\\s*`, "g");
|
||||
normalizedExpr = normalizedExpr.replace(pattern, "$1$2$3");
|
||||
}
|
||||
|
||||
const tokenSpec: [RegExp, string | null][] = [
|
||||
[/\|\|/, "OR"],
|
||||
[/&/, "AND"],
|
||||
[/\(/, "LPAREN"],
|
||||
[/\)/, "RPAREN"],
|
||||
[/[^\s&|()]+/, "ATOM"],
|
||||
[/\s+/, null],
|
||||
];
|
||||
const regex = new RegExp(
|
||||
tokenSpec.map(([pat, _name], i) => `(?<T${i}>${pat.source})`).join("|"),
|
||||
"g"
|
||||
);
|
||||
const tokens: [string, string][] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = regex.exec(normalizedExpr)) !== null) {
|
||||
for (let i = 0; i < tokenSpec.length; i++) {
|
||||
if (m.groups?.[`T${i}`]) {
|
||||
const tokenSpecEntry = tokenSpec[i];
|
||||
if (tokenSpecEntry) {
|
||||
const kind = tokenSpecEntry[1];
|
||||
if (kind) tokens.push([kind, m[0]]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private _accept(kind: string): boolean {
|
||||
if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) {
|
||||
this.pos++;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _expect(kind: string): [string, string] {
|
||||
if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) {
|
||||
return this.tokens[this.pos++]!;
|
||||
}
|
||||
throw new SyntaxError(`Expected ${kind}`);
|
||||
}
|
||||
|
||||
private _parseOr(): Node {
|
||||
let left = this._parseAnd();
|
||||
while (this._accept("OR")) {
|
||||
const right = this._parseAnd();
|
||||
left = ["OR", left, right];
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private _parseAnd(): Node {
|
||||
let left = this._parseAtom();
|
||||
while (this._accept("AND")) {
|
||||
const right = this._parseAtom();
|
||||
left = ["AND", left, right];
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private _parseAtom(): Node {
|
||||
if (this._accept("LPAREN")) {
|
||||
const node = this._parseOr();
|
||||
this._expect("RPAREN");
|
||||
return node;
|
||||
}
|
||||
const tok = this._expect("ATOM");
|
||||
return ["ATOM", tok[1].trim()];
|
||||
}
|
||||
|
||||
private _eval(node: Node, dct: Record<string, any>): boolean {
|
||||
if (node[0] === "ATOM") {
|
||||
return _match_one(node[1], dct);
|
||||
}
|
||||
if (node[0] === "AND") {
|
||||
return this._eval(node[1], dct) && this._eval(node[2], dct);
|
||||
}
|
||||
if (node[0] === "OR") {
|
||||
return this._eval(node[1], dct) || this._eval(node[2], dct);
|
||||
}
|
||||
throw new Error("Invalid AST node " + node[0]);
|
||||
}
|
||||
|
||||
private _export(node: Node): string[][] {
|
||||
if (node[0] === "ATOM") return [[node[1]]];
|
||||
if (node[0] === "AND") {
|
||||
const left = this._export(node[1]);
|
||||
const right = this._export(node[2]);
|
||||
return left.flatMap(l => right.map(r => [...l, ...r]));
|
||||
}
|
||||
if (node[0] === "OR") {
|
||||
return [...this._export(node[1]), ...this._export(node[2])];
|
||||
}
|
||||
throw new Error("Invalid AST node " + node[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const match_str = (expr: string, dct: Record<string, any>): boolean => MatchFilterParser.run(expr, dct)
|
||||
|
||||
export { MatchFilterParser, match_str }
|
||||
|
|
|
|||
54
ui/eslint.config.js
Normal file
54
ui/eslint.config.js
Normal 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: {}
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2257
ui/pnpm-lock.yaml
2257
ui/pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
95
uv.lock
95
uv.lock
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue