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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -14,14 +14,37 @@ on:
|
||||||
env:
|
env:
|
||||||
PNPM_VERSION: 10
|
PNPM_VERSION: 10
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-pr:
|
test:
|
||||||
|
name: Run Tests & Build Validation
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -34,16 +57,39 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||||
|
|
||||||
- name: Install frontend dependencies & Build
|
- name: Install frontend dependencies
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: |
|
env:
|
||||||
pnpm install --production --prefer-offline --frozen-lockfile
|
NODE_ENV: development
|
||||||
pnpm run generate
|
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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build commit to check (amd64 only)
|
- name: Validate Docker build
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -52,4 +98,3 @@ jobs:
|
||||||
cache-from: type=gha,scope=${{ github.workflow }}
|
cache-from: type=gha,scope=${{ github.workflow }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
|
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
|
||||||
provenance: false
|
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
|
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||||
PNPM_VERSION: 10
|
PNPM_VERSION: 10
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 20
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build-arch:
|
test:
|
||||||
name: Build Container (${{ matrix.arch }})
|
name: Run Tests & Prepare Frontend
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- os: ubuntu-latest
|
|
||||||
arch: amd64
|
|
||||||
- os: ubuntu-latest
|
|
||||||
arch: arm64
|
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
contents: read
|
||||||
contents: write
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
ARCH: ${{ matrix.arch }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
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
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -61,12 +73,41 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
cache-dependency-path: "ui/pnpm-lock.yaml"
|
cache-dependency-path: "ui/pnpm-lock.yaml"
|
||||||
|
|
||||||
- name: Install frontend dependencies & Build
|
- name: Install frontend dependencies
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: |
|
env:
|
||||||
pnpm install --production --prefer-offline --frozen-lockfile
|
NODE_ENV: development
|
||||||
pnpm run generate
|
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
|
- name: Install GitPython
|
||||||
run: pip install gitpython
|
run: pip install gitpython
|
||||||
|
|
||||||
|
|
@ -93,6 +134,42 @@ jobs:
|
||||||
-e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \
|
-e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \
|
||||||
app/library/version.py
|
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
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -37,3 +37,5 @@ __pycache__
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
version.txt
|
version.txt
|
||||||
|
test_impl.py
|
||||||
|
./eslint.config.js
|
||||||
|
|
|
||||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
|
|
@ -87,6 +87,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"justMyCode": true
|
"justMyCode": true
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
33
.vscode/settings.json
vendored
33
.vscode/settings.json
vendored
|
|
@ -12,12 +12,14 @@
|
||||||
],
|
],
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"aconvert",
|
"aconvert",
|
||||||
|
"addopts",
|
||||||
"ahas",
|
"ahas",
|
||||||
"ahash",
|
"ahash",
|
||||||
"aiocron",
|
"aiocron",
|
||||||
"anyio",
|
"anyio",
|
||||||
"Archiver",
|
"Archiver",
|
||||||
"arrowless",
|
"arrowless",
|
||||||
|
"asyncio",
|
||||||
"attl",
|
"attl",
|
||||||
"autonumber",
|
"autonumber",
|
||||||
"bgutil",
|
"bgutil",
|
||||||
|
|
@ -26,6 +28,7 @@
|
||||||
"brainicism",
|
"brainicism",
|
||||||
"brotlicffi",
|
"brotlicffi",
|
||||||
"buildcache",
|
"buildcache",
|
||||||
|
"Cfmrc",
|
||||||
"choco",
|
"choco",
|
||||||
"consoletitle",
|
"consoletitle",
|
||||||
"continuedl",
|
"continuedl",
|
||||||
|
|
@ -39,20 +42,27 @@
|
||||||
"daterange",
|
"daterange",
|
||||||
"defusedxml",
|
"defusedxml",
|
||||||
"dlfields",
|
"dlfields",
|
||||||
|
"dotdot",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"dpkg",
|
"dpkg",
|
||||||
|
"dunder",
|
||||||
|
"Edes",
|
||||||
"edgechromium",
|
"edgechromium",
|
||||||
|
"EISGPM",
|
||||||
"engineio",
|
"engineio",
|
||||||
"Errno",
|
"Errno",
|
||||||
"esac",
|
"esac",
|
||||||
"euuo",
|
"euuo",
|
||||||
"excepthook",
|
"excepthook",
|
||||||
"faststart",
|
"faststart",
|
||||||
|
"Fetc",
|
||||||
"finaldir",
|
"finaldir",
|
||||||
"flac",
|
"flac",
|
||||||
"forcejson",
|
"forcejson",
|
||||||
"forceprint",
|
"forceprint",
|
||||||
|
"Fpasswd",
|
||||||
"fribidi",
|
"fribidi",
|
||||||
|
"Funsafe",
|
||||||
"getpid",
|
"getpid",
|
||||||
"gibibytes",
|
"gibibytes",
|
||||||
"gitpython",
|
"gitpython",
|
||||||
|
|
@ -62,12 +72,14 @@
|
||||||
"httpx",
|
"httpx",
|
||||||
"imagetools",
|
"imagetools",
|
||||||
"kibibytes",
|
"kibibytes",
|
||||||
|
"lastgroup",
|
||||||
"levelno",
|
"levelno",
|
||||||
"libcurl",
|
"libcurl",
|
||||||
"libjavascriptcoregtk",
|
"libjavascriptcoregtk",
|
||||||
"libstdc",
|
"libstdc",
|
||||||
"libwebkit",
|
"libwebkit",
|
||||||
"libx",
|
"libx",
|
||||||
|
"LPAREN",
|
||||||
"matchtitle",
|
"matchtitle",
|
||||||
"matroska",
|
"matroska",
|
||||||
"mbed",
|
"mbed",
|
||||||
|
|
@ -89,6 +101,7 @@
|
||||||
"noprogress",
|
"noprogress",
|
||||||
"onefile",
|
"onefile",
|
||||||
"pathex",
|
"pathex",
|
||||||
|
"pickleable",
|
||||||
"platformdirs",
|
"platformdirs",
|
||||||
"playlistend",
|
"playlistend",
|
||||||
"playlistrandom",
|
"playlistrandom",
|
||||||
|
|
@ -102,10 +115,12 @@
|
||||||
"pycryptodomex",
|
"pycryptodomex",
|
||||||
"pyinstaller",
|
"pyinstaller",
|
||||||
"pypi",
|
"pypi",
|
||||||
|
"pythonpath",
|
||||||
"quicktime",
|
"quicktime",
|
||||||
"rejecttitle",
|
"rejecttitle",
|
||||||
"remux",
|
"remux",
|
||||||
"reqs",
|
"reqs",
|
||||||
|
"RPAREN",
|
||||||
"rtime",
|
"rtime",
|
||||||
"rvfc",
|
"rvfc",
|
||||||
"SIGUSR",
|
"SIGUSR",
|
||||||
|
|
@ -113,11 +128,14 @@
|
||||||
"socketio",
|
"socketio",
|
||||||
"softprops",
|
"softprops",
|
||||||
"sstr",
|
"sstr",
|
||||||
|
"startswith",
|
||||||
"SUPPRESSHELP",
|
"SUPPRESSHELP",
|
||||||
"tebibytes",
|
"tebibytes",
|
||||||
|
"testpaths",
|
||||||
"tiktok",
|
"tiktok",
|
||||||
"timespec",
|
"timespec",
|
||||||
"tmpfilename",
|
"tmpfilename",
|
||||||
|
"toplevel",
|
||||||
"ungroup",
|
"ungroup",
|
||||||
"unmark",
|
"unmark",
|
||||||
"unnegated",
|
"unnegated",
|
||||||
|
|
@ -130,20 +148,17 @@
|
||||||
"ustr",
|
"ustr",
|
||||||
"vcodec",
|
"vcodec",
|
||||||
"vconvert",
|
"vconvert",
|
||||||
|
"Vitest",
|
||||||
"writedescription",
|
"writedescription",
|
||||||
"xerror",
|
"xerror",
|
||||||
"youtu",
|
"youtu",
|
||||||
"youtubepot"
|
"youtubepot",
|
||||||
|
"русский",
|
||||||
|
"العربية"
|
||||||
],
|
],
|
||||||
"css.styleSheets": [
|
"css.styleSheets": [
|
||||||
"ui/app/assets/css/*.css"
|
"ui/app/assets/css/*.css"
|
||||||
],
|
],
|
||||||
"spellright.language": [
|
|
||||||
"en"
|
|
||||||
],
|
|
||||||
"spellright.documentTypes": [
|
|
||||||
"latex"
|
|
||||||
],
|
|
||||||
"search.exclude": {
|
"search.exclude": {
|
||||||
"ui/.nuxt": true,
|
"ui/.nuxt": true,
|
||||||
"ui/.output": true,
|
"ui/.output": true,
|
||||||
|
|
@ -152,5 +167,7 @@
|
||||||
"ui/node_modules": true,
|
"ui/node_modules": true,
|
||||||
"ui/.out": true,
|
"ui/.out": true,
|
||||||
"**/.venv": true,
|
"**/.venv": true,
|
||||||
}
|
},
|
||||||
|
"eslint.format.enable": true,
|
||||||
|
"eslint.ignoreUntitled": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -151,7 +150,7 @@ class DataStore:
|
||||||
if "error" == value.info.status and not no_notify:
|
if "error" == value.info.status and not no_notify:
|
||||||
from app.library.Events import EventBus, Events
|
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._dict.update({value.info._id: value})
|
||||||
self._update_store_item(self._type, value.info)
|
self._update_store_item(self._type, value.info)
|
||||||
|
|
|
||||||
|
|
@ -350,7 +350,7 @@ class Download:
|
||||||
self.proc.start()
|
self.proc.start()
|
||||||
self.info.status = "preparing"
|
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}")
|
asyncio.create_task(self.progress_update(), name=f"update-{self.id}")
|
||||||
|
|
||||||
ret = await asyncio.get_running_loop().run_in_executor(None, self.proc.join)
|
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=}")
|
self.logger.debug(f"Status Update: {self.info._id=} {status=}")
|
||||||
|
|
||||||
if isinstance(status, str):
|
if isinstance(status, str):
|
||||||
await self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
self._notify.emit(Events.ITEM_UPDATED, data=self.info)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.tmpfilename: str | None = status.get("tmpfilename")
|
self.tmpfilename: str | None = status.get("tmpfilename")
|
||||||
|
|
@ -547,7 +547,7 @@ class Download:
|
||||||
|
|
||||||
if "error" == self.info.status and "error" in status:
|
if "error" == self.info.status and "error" in status:
|
||||||
self.info.error = status.get("error")
|
self.info.error = status.get("error")
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.LOG_ERROR,
|
Events.LOG_ERROR,
|
||||||
data=self.info,
|
data=self.info,
|
||||||
title="Download Error",
|
title="Download Error",
|
||||||
|
|
@ -584,7 +584,7 @@ class Download:
|
||||||
self.logger.error(f"Failed to run ffprobe. {status.get}. {e}")
|
self.logger.error(f"Failed to run ffprobe. {status.get}. {e}")
|
||||||
|
|
||||||
if not self.final_update or fl:
|
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):
|
async def progress_update(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import functools
|
||||||
import glob
|
import glob
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from email.utils import formatdate
|
from email.utils import formatdate
|
||||||
|
|
@ -107,9 +108,11 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
_ (web.Application): The application to attach the download queue to.
|
_ (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(
|
Scheduler.get_instance().add(
|
||||||
timer="* * * * *",
|
timer="* * * * *",
|
||||||
|
|
@ -155,7 +158,6 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
status: dict[str, str] = {"status": "ok"}
|
status: dict[str, str] = {"status": "ok"}
|
||||||
started = False
|
started = False
|
||||||
tasks: list = []
|
|
||||||
|
|
||||||
for item_id in ids:
|
for item_id in ids:
|
||||||
try:
|
try:
|
||||||
|
|
@ -173,14 +175,12 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
item.info.auto_start = True
|
item.info.auto_start = True
|
||||||
updated: Download = self.queue.put(item)
|
updated: Download = self.queue.put(item)
|
||||||
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
|
self._notify.emit(Events.ITEM_UPDATED, data=updated.info)
|
||||||
tasks.append(
|
self._notify.emit(
|
||||||
self._notify.emit(
|
Events.ITEM_RESUMED,
|
||||||
Events.ITEM_RESUMED,
|
data=item.info,
|
||||||
data=item.info,
|
title="Download Resumed",
|
||||||
title="Download Resumed",
|
message=f"Download '{item.info.title}' has been resumed.",
|
||||||
message=f"Download '{item.info.title}' has been resumed.",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
status[item_id] = "started"
|
status[item_id] = "started"
|
||||||
started = True
|
started = True
|
||||||
|
|
@ -189,9 +189,6 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
if started:
|
if started:
|
||||||
self.event.set()
|
self.event.set()
|
||||||
|
|
||||||
if len(tasks) > 0:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
async def pause_items(self, ids: list[str]) -> dict[str, str]:
|
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"}
|
status: dict[str, str] = {"status": "ok"}
|
||||||
tasks: list = []
|
|
||||||
|
|
||||||
for item_id in ids:
|
for item_id in ids:
|
||||||
try:
|
try:
|
||||||
|
|
@ -229,21 +225,16 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
item.info.auto_start = False
|
item.info.auto_start = False
|
||||||
updated: Download = self.queue.put(item)
|
updated: Download = self.queue.put(item)
|
||||||
tasks.append(self._notify.emit(Events.ITEM_UPDATED, data=updated.info))
|
self._notify.emit(Events.ITEM_UPDATED, data=updated.info)
|
||||||
tasks.append(
|
self._notify.emit(
|
||||||
self._notify.emit(
|
Events.ITEM_PAUSED,
|
||||||
Events.ITEM_PAUSED,
|
data=item.info,
|
||||||
data=item.info,
|
title="Download Paused",
|
||||||
title="Download Paused",
|
message=f"Download '{item.info.title}' has been paused.",
|
||||||
message=f"Download '{item.info.title}' has been paused.",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
status[item_id] = "paused"
|
status[item_id] = "paused"
|
||||||
LOG.debug(f"Item {item.info.name()} marked as paused.")
|
LOG.debug(f"Item {item.info.name()} marked as paused.")
|
||||||
|
|
||||||
if len(tasks) > 0:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def pause(self, shutdown: bool = False) -> bool:
|
def pause(self, shutdown: bool = False) -> bool:
|
||||||
|
|
@ -495,7 +486,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
dlInfo.info.status = "not_live"
|
dlInfo.info.status = "not_live"
|
||||||
dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "")
|
dlInfo.info.msg = nMessage.replace(f" '{dlInfo.info.title}'", "")
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.LOG_INFO,
|
Events.LOG_INFO,
|
||||||
data={"preset": dlInfo.info.preset, "lowPriority": True},
|
data={"preset": dlInfo.info.preset, "lowPriority": True},
|
||||||
title=nTitle,
|
title=nTitle,
|
||||||
|
|
@ -517,7 +508,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
dlInfo.info.status = "error"
|
dlInfo.info.status = "error"
|
||||||
itemDownload = self.done.put(dlInfo)
|
itemDownload = self.done.put(dlInfo)
|
||||||
|
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.LOG_WARNING,
|
Events.LOG_WARNING,
|
||||||
data={"preset": dlInfo.info.preset, "logs": text_logs},
|
data={"preset": dlInfo.info.preset, "logs": text_logs},
|
||||||
title=nTitle,
|
title=nTitle,
|
||||||
|
|
@ -557,7 +548,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
nStore = "history"
|
nStore = "history"
|
||||||
nEvent = Events.ITEM_MOVED
|
nEvent = Events.ITEM_MOVED
|
||||||
nTitle = "Premiering right now"
|
nTitle = "Premiering right now"
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.LOG_INFO, data={"preset": dlInfo.info.preset}, title=nTitle, message=nMessage
|
Events.LOG_INFO, data={"preset": dlInfo.info.preset}, title=nTitle, message=nMessage
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -570,7 +561,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
else:
|
else:
|
||||||
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
|
LOG.debug(f"Item {itemDownload.info.name()} is not set to auto-start.")
|
||||||
|
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
nEvent,
|
nEvent,
|
||||||
data={"to": nStore, "preset": itemDownload.info.preset, "item": itemDownload.info}
|
data={"to": nStore, "preset": itemDownload.info.preset, "item": itemDownload.info}
|
||||||
if Events.ITEM_MOVED == nEvent
|
if Events.ITEM_MOVED == nEvent
|
||||||
|
|
@ -702,7 +693,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
|
|
||||||
self.done.put(dlInfo)
|
self.done.put(dlInfo)
|
||||||
|
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.ITEM_MOVED,
|
Events.ITEM_MOVED,
|
||||||
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
|
data={"to": "history", "preset": dlInfo.info.preset, "item": dlInfo.info},
|
||||||
title="Download History Update",
|
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."
|
message: str = f"The URL '{item.url}' is already downloaded and recorded in archive."
|
||||||
LOG.error(message)
|
LOG.error(message)
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.LOG_INFO, data={"preset": item.preset}, title="Already Downloaded", message=message
|
Events.LOG_INFO, data={"preset": item.preset}, title="Already Downloaded", message=message
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -866,7 +857,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
await item.close()
|
await item.close()
|
||||||
LOG.debug(f"Deleting from queue {item_ref}")
|
LOG.debug(f"Deleting from queue {item_ref}")
|
||||||
self.queue.delete(id)
|
self.queue.delete(id)
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.ITEM_CANCELLED,
|
Events.ITEM_CANCELLED,
|
||||||
data=item.info,
|
data=item.info,
|
||||||
title="Download Cancelled",
|
title="Download Cancelled",
|
||||||
|
|
@ -874,7 +865,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
)
|
)
|
||||||
item.info.status = "cancelled"
|
item.info.status = "cancelled"
|
||||||
self.done.put(item)
|
self.done.put(item)
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.ITEM_MOVED,
|
Events.ITEM_MOVED,
|
||||||
data={"to": "history", "preset": item.info.preset, "item": item.info},
|
data={"to": "history", "preset": item.info.preset, "item": item.info},
|
||||||
title="Download Cancelled",
|
title="Download Cancelled",
|
||||||
|
|
@ -949,7 +940,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
self.done.delete(id)
|
self.done.delete(id)
|
||||||
|
|
||||||
_status: str = "Removed" if removed_files > 0 else "Cleared"
|
_status: str = "Removed" if removed_files > 0 else "Cleared"
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.ITEM_DELETED,
|
Events.ITEM_DELETED,
|
||||||
data=item.info,
|
data=item.info,
|
||||||
title=f"Download {_status}",
|
title=f"Download {_status}",
|
||||||
|
|
@ -1086,7 +1077,6 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
await entry.close()
|
await entry.close()
|
||||||
|
|
||||||
if self.queue.exists(key=id):
|
if self.queue.exists(key=id):
|
||||||
_tasks = []
|
|
||||||
LOG.debug(f"Download Task '{id}' is completed. Removing from queue.")
|
LOG.debug(f"Download Task '{id}' is completed. Removing from queue.")
|
||||||
self.queue.delete(key=id)
|
self.queue.delete(key=id)
|
||||||
|
|
||||||
|
|
@ -1096,7 +1086,7 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
if entry.is_cancelled() is True:
|
if entry.is_cancelled() is True:
|
||||||
nTitle = "Download Cancelled"
|
nTitle = "Download Cancelled"
|
||||||
nMessage = f"Cancelled '{entry.info.title}' download."
|
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"
|
entry.info.status = "cancelled"
|
||||||
|
|
||||||
if entry.info.status == "finished" and entry.info.filename:
|
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:
|
if entry.info.is_archivable and not entry.info.is_archived:
|
||||||
entry.info.is_archived = True
|
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)
|
self.done.put(entry)
|
||||||
_tasks.append(
|
self._notify.emit(
|
||||||
self._notify.emit(
|
Events.ITEM_MOVED,
|
||||||
Events.ITEM_MOVED,
|
data={"to": "history", "preset": entry.info.preset, "item": entry.info},
|
||||||
data={"to": "history", "preset": entry.info.preset, "item": entry.info},
|
title=nTitle,
|
||||||
title=nTitle,
|
message=nMessage,
|
||||||
message=nMessage,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.gather(*_tasks)
|
|
||||||
else:
|
else:
|
||||||
LOG.warning(f"Download '{id}' not found in queue.")
|
LOG.warning(f"Download '{id}' not found in queue.")
|
||||||
|
|
||||||
|
|
@ -1223,7 +1209,6 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
return
|
return
|
||||||
|
|
||||||
if exc := task.exception():
|
if exc := task.exception():
|
||||||
import traceback
|
|
||||||
|
|
||||||
task_name: str = task.get_name() if task.get_name() else "unknown_task"
|
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()}")
|
LOG.error(f"Unhandled exception in background task '{task_name}': {exc!s}. {traceback.format_exc()}")
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,9 @@ class Event:
|
||||||
data: Any
|
data: Any
|
||||||
"""The data that was passed to the event."""
|
"""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:
|
def serialize(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Serialize the event.
|
Serialize the event.
|
||||||
|
|
@ -162,9 +165,20 @@ class Event:
|
||||||
"data": self.data,
|
"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})"
|
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:
|
def datatype(self) -> str:
|
||||||
"""
|
"""
|
||||||
Get the datatype of the data.
|
Get the datatype of the data.
|
||||||
|
|
@ -210,17 +224,12 @@ class EventBus(metaclass=Singleton):
|
||||||
debug: bool = False
|
debug: bool = False
|
||||||
"""Whether to log debug messages or not."""
|
"""Whether to log debug messages or not."""
|
||||||
|
|
||||||
_offload: BackgroundWorker
|
_offload: BackgroundWorker = None
|
||||||
"""The background worker to offload tasks to."""
|
"""The background worker to offload tasks to."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
EventBus._instance = self
|
EventBus._instance = self
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
|
|
||||||
self.debug = Config.get_instance().debug
|
|
||||||
self._offload = BackgroundWorker.get_instance()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_instance() -> "EventBus":
|
def get_instance() -> "EventBus":
|
||||||
"""
|
"""
|
||||||
|
|
@ -305,84 +314,11 @@ class EventBus(metaclass=Singleton):
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def sync_emit(
|
def 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(
|
|
||||||
self, event: str, data: Any | None = None, title: str | None = None, message: str | None = None, **kwargs
|
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:
|
Args:
|
||||||
event (str): The event to emit.
|
event (str): The event to emit.
|
||||||
|
|
@ -391,44 +327,6 @@ class EventBus(metaclass=Singleton):
|
||||||
message (str | None): The message of the event, if any.
|
message (str | None): The message of the event, if any.
|
||||||
**kwargs: The keyword arguments to pass to the event.
|
**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:
|
if event not in self._listeners:
|
||||||
return
|
return
|
||||||
|
|
@ -439,11 +337,57 @@ class EventBus(metaclass=Singleton):
|
||||||
ev = Event(event=event, title=title, message=message, data=data)
|
ev = Event(event=event, title=title, message=message, data=data)
|
||||||
|
|
||||||
if self.debug or event not in Events.only_debug():
|
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:
|
||||||
try:
|
loop = asyncio.get_running_loop()
|
||||||
self._offload.submit(handler.handle, ev, **kwargs)
|
|
||||||
except Exception as e:
|
for handler in self._listeners[event].values():
|
||||||
LOG.exception(e)
|
try:
|
||||||
LOG.error(f"Failed to offload event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
|
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,
|
ping_timeout=5,
|
||||||
)
|
)
|
||||||
encoder = encoder or Encoder()
|
encoder = encoder or Encoder()
|
||||||
self.rootPath = root_path
|
self.rootPath: Path = root_path
|
||||||
|
|
||||||
def emit(e: Event, _, **kwargs):
|
async def event_handler(e: Event, _, **kwargs):
|
||||||
return self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
|
await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
|
||||||
|
|
||||||
services = Services.get_instance()
|
services = Services.get_instance()
|
||||||
services.add_all(
|
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
|
@staticmethod
|
||||||
def ws_event(func): # type: ignore
|
def ws_event(func): # type: ignore
|
||||||
|
|
@ -101,11 +101,12 @@ class HttpSocket:
|
||||||
app.on_shutdown.append(self.on_shutdown)
|
app.on_shutdown.append(self.on_shutdown)
|
||||||
|
|
||||||
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
|
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
|
||||||
self._notify.subscribe(
|
|
||||||
Events.ADD_URL,
|
async def event_handler(data: Event, _):
|
||||||
lambda data, _, **kwargs: self.queue.add(item=Item.format(data.data)), # noqa: ARG005
|
if data and data.data:
|
||||||
f"{__class__.__name__}.add",
|
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")
|
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}'.")
|
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)}
|
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):
|
if len(self._targets) < 1 or not NotificationEvents.is_valid(e.event):
|
||||||
return self.noop()
|
return
|
||||||
|
|
||||||
self._offload.submit(self.send, e)
|
self._offload.submit(self.send, e)
|
||||||
|
return
|
||||||
return self.noop()
|
|
||||||
|
|
||||||
def _deep_unpack(self, data: dict) -> dict:
|
def _deep_unpack(self, data: dict) -> dict:
|
||||||
for k, v in data.items():
|
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}'.")
|
LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
def event_handler(_, __):
|
async def event_handler(_, __):
|
||||||
msg = "Not implemented"
|
msg = "Not implemented"
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,11 @@ class Scheduler(metaclass=Singleton):
|
||||||
|
|
||||||
self._loop = loop or asyncio.get_event_loop()
|
self._loop = loop or asyncio.get_event_loop()
|
||||||
|
|
||||||
EventBus.get_instance().subscribe(
|
async def event_handler(data, _):
|
||||||
Events.SCHEDULE_ADD,
|
if data and data.data:
|
||||||
lambda data, _, **kwargs: self.add(**data.data), # noqa: ARG005
|
self.add(**data.data)
|
||||||
f"{__class__.__name__}.add",
|
|
||||||
)
|
EventBus.get_instance().subscribe(Events.SCHEDULE_ADD, event_handler, f"{__class__.__name__}.add")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_instance() -> "Scheduler":
|
def get_instance() -> "Scheduler":
|
||||||
|
|
|
||||||
|
|
@ -193,11 +193,12 @@ class Tasks(metaclass=Singleton):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self.load()
|
self.load()
|
||||||
self._notify.subscribe(
|
|
||||||
Events.TASKS_ADD,
|
async def event_handler(data, _):
|
||||||
lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005
|
if data and data.data:
|
||||||
f"{__class__.__name__}.add",
|
self.save(data.data)
|
||||||
)
|
|
||||||
|
self._notify.subscribe(Events.TASKS_ADD, event_handler, f"{__class__.__name__}.add")
|
||||||
self._task_handler.load()
|
self._task_handler.load()
|
||||||
|
|
||||||
def get_all(self) -> list[Task]:
|
def get_all(self) -> list[Task]:
|
||||||
|
|
@ -449,7 +450,7 @@ class Tasks(metaclass=Singleton):
|
||||||
await asyncio.gather(*_tasks)
|
await asyncio.gather(*_tasks)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
||||||
await self._notify.emit(
|
self._notify.emit(
|
||||||
Events.LOG_ERROR,
|
Events.LOG_ERROR,
|
||||||
data={"preset": task.preset},
|
data={"preset": task.preset},
|
||||||
title="Task failed",
|
title="Task failed",
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ from pathlib import Path
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
from yt_dlp.utils import age_restricted, match_str
|
from yt_dlp.utils import age_restricted
|
||||||
|
|
||||||
from .LogWrapper import LogWrapper
|
from .LogWrapper import LogWrapper
|
||||||
|
from .mini_filter import match_str
|
||||||
from .ytdlp import YTDLP
|
from .ytdlp import YTDLP
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("Utils")
|
LOG: logging.Logger = logging.getLogger("Utils")
|
||||||
|
|
@ -225,38 +226,116 @@ def extract_info(
|
||||||
return YTDLP.sanitize_info(data) if sanitize_info else data
|
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:
|
Args:
|
||||||
source (dict): Source data
|
source (dict): Source data
|
||||||
destination (dict): Destination 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:
|
Returns:
|
||||||
dict: The merged dictionary
|
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):
|
if not isinstance(source, dict) or not isinstance(destination, dict):
|
||||||
msg = "Both source and destination must be dictionaries."
|
msg = "Both source and destination must be dictionaries."
|
||||||
raise TypeError(msg)
|
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():
|
for key, value in source.items():
|
||||||
if key in {"__class__", "__dict__", "__globals__", "__builtins__"}:
|
# Skip unsafe keys
|
||||||
|
if not _is_safe_key(key):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
destination_value = destination_copy.get(key)
|
destination_value = destination_copy.get(key)
|
||||||
|
|
||||||
# Recursively merge dictionaries
|
# Recursively merge dictionaries with safety checks
|
||||||
if isinstance(value, dict) and isinstance(destination_value, dict):
|
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):
|
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:
|
else:
|
||||||
destination_copy[key] = copy.deepcopy(value)
|
destination_copy[key] = copy.deepcopy(value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from aiohttp import web
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .encoder import Encoder
|
from .encoder import Encoder
|
||||||
from .Events import EventBus, Events
|
from .Events import EventBus, Events
|
||||||
|
from .mini_filter import match_str
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
from .Utils import arg_converter, init_class
|
from .Utils import arg_converter, init_class
|
||||||
|
|
||||||
|
|
@ -67,7 +68,7 @@ class Conditions(metaclass=Singleton):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def event_handler(_, __):
|
async def event_handler(_, __):
|
||||||
msg = "Not implemented"
|
msg = "Not implemented"
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
|
|
@ -183,8 +184,6 @@ class Conditions(metaclass=Singleton):
|
||||||
bool: True if valid, False otherwise.
|
bool: True if valid, False otherwise.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from yt_dlp.utils import match_str
|
|
||||||
|
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
if not isinstance(item, Condition):
|
if not isinstance(item, Condition):
|
||||||
msg = f"Unexpected '{type(item).__name__}' item type."
|
msg = f"Unexpected '{type(item).__name__}' item type."
|
||||||
|
|
@ -306,8 +305,6 @@ class Conditions(metaclass=Singleton):
|
||||||
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
|
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from yt_dlp.utils import match_str
|
|
||||||
|
|
||||||
for item in self.get_all():
|
for item in self.get_all():
|
||||||
if not item.filter:
|
if not item.filter:
|
||||||
LOG.error(f"Filter is empty for '{item.name}'.")
|
LOG.error(f"Filter is empty for '{item.name}'.")
|
||||||
|
|
@ -343,6 +340,4 @@ class Conditions(metaclass=Singleton):
|
||||||
if not item or not item.filter:
|
if not item or not item.filter:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from yt_dlp.utils import match_str
|
|
||||||
|
|
||||||
return item if match_str(item.filter, info) else None
|
return item if match_str(item.filter, info) else None
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ class DLFields(metaclass=Singleton):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def event_handler(_, __):
|
async def event_handler(_, __):
|
||||||
msg = "Not implemented"
|
msg = "Not implemented"
|
||||||
raise Exception(msg)
|
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__:
|
if "failure_count" not in cls.__dict__:
|
||||||
cls.failure_count = {}
|
cls.failure_count = {}
|
||||||
|
|
||||||
EventBus.get_instance().subscribe(
|
async def event_handler(data, _):
|
||||||
Events.ITEM_ERROR,
|
if data and data.data:
|
||||||
lambda data, _, **__: cls.on_error(data.data),
|
await cls.on_error(data.data)
|
||||||
f"{cls.__name__}.on_error",
|
|
||||||
)
|
EventBus.get_instance().subscribe(Events.ITEM_ERROR, event_handler, f"{cls.__name__}.on_error")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_handle(task: Task) -> bool:
|
def can_handle(task: Task) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -146,9 +145,8 @@ class TwitchHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
for item in filtered:
|
||||||
*[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
|
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
|
LOG.error(f"Error while adding items from '{task.name}'. {e!s}")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -153,9 +152,8 @@ class YoutubeHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
for item in filtered:
|
||||||
*[notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize()) for item in filtered]
|
notify.emit(Events.ADD_URL, data=rItem.new_with(url=item["url"]).serialize())
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
LOG.error(f"'{task.name}': Error while adding items from task feed. {e!s}")
|
LOG.error(f"'{task.name}': Error while adding items from task feed. {e!s}")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# flake8: noqa: SIM115 S310 T201
|
# flake8: noqa: S310 T201
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
15
app/main.py
15
app/main.py
|
|
@ -38,7 +38,7 @@ ROOT_PATH: Path = Path(__file__).parent.absolute()
|
||||||
|
|
||||||
class Main:
|
class Main:
|
||||||
def __init__(self, is_native: bool = False):
|
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 = web.Application()
|
||||||
self._app.on_shutdown.append(self.on_shutdown)
|
self._app.on_shutdown.append(self.on_shutdown)
|
||||||
self._background_worker = BackgroundWorker()
|
self._background_worker = BackgroundWorker()
|
||||||
|
|
@ -98,7 +98,7 @@ class Main:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application):
|
async def on_shutdown(self, _: web.Application):
|
||||||
await EventBus.get_instance().emit(
|
EventBus.get_instance().emit(
|
||||||
Events.SHUTDOWN,
|
Events.SHUTDOWN,
|
||||||
data={"app": self._app},
|
data={"app": self._app},
|
||||||
title="Application Shutdown",
|
title="Application Shutdown",
|
||||||
|
|
@ -112,12 +112,15 @@ class Main:
|
||||||
host = host or self._config.host
|
host = host or self._config.host
|
||||||
port = port or self._config.port
|
port = port or self._config.port
|
||||||
|
|
||||||
EventBus.get_instance().sync_emit(
|
EventBus.get_instance().emit(
|
||||||
Events.STARTUP,
|
Events.STARTUP,
|
||||||
data={"app": self._app},
|
data={"app": self._app},
|
||||||
title="Application Startup",
|
title="Application Startup",
|
||||||
message="The application is starting up.",
|
message="The application is starting up.",
|
||||||
)
|
)
|
||||||
|
if self._config.debug:
|
||||||
|
EventBus.get_instance().debug_enable()
|
||||||
|
|
||||||
Scheduler.get_instance().attach(self._app)
|
Scheduler.get_instance().attach(self._app)
|
||||||
|
|
||||||
self._socket.attach(self._app)
|
self._socket.attach(self._app)
|
||||||
|
|
@ -131,7 +134,7 @@ class Main:
|
||||||
DLFields.get_instance().attach(self._app)
|
DLFields.get_instance().attach(self._app)
|
||||||
self._background_worker.attach(self._app)
|
self._background_worker.attach(self._app)
|
||||||
|
|
||||||
EventBus.get_instance().sync_emit(
|
EventBus.get_instance().emit(
|
||||||
Events.LOADED,
|
Events.LOADED,
|
||||||
data={"app": self._app},
|
data={"app": self._app},
|
||||||
title="Application Loaded",
|
title="Application Loaded",
|
||||||
|
|
@ -147,13 +150,11 @@ class Main:
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
EventBus.get_instance().sync_emit(
|
EventBus.get_instance().emit(
|
||||||
Events.STARTED,
|
Events.STARTED,
|
||||||
data={"app": self._app},
|
data={"app": self._app},
|
||||||
title="Application Started",
|
title="Application Started",
|
||||||
message="The application has started successfully.",
|
message="The application has started successfully.",
|
||||||
loop=loop,
|
|
||||||
wait=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if loop and self._config.debug:
|
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.config import Config
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.Events import EventBus, Events
|
from app.library.Events import EventBus, Events
|
||||||
|
from app.library.mini_filter import match_str
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
from app.library.Utils import extract_info, init_class, validate_uuid
|
from app.library.Utils import extract_info, init_class, validate_uuid
|
||||||
from app.library.YTDLPOpts import YTDLPOpts
|
from app.library.YTDLPOpts import YTDLPOpts
|
||||||
|
|
@ -113,7 +114,7 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -
|
||||||
status=web.HTTPInternalServerError.status_code,
|
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)
|
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:
|
try:
|
||||||
from yt_dlp.utils import match_str
|
|
||||||
|
|
||||||
status = match_str(cond, data)
|
status = match_str(cond, data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
|
|
|
||||||
|
|
@ -96,5 +96,5 @@ async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) ->
|
||||||
status=web.HTTPInternalServerError.status_code,
|
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)
|
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:
|
if updated:
|
||||||
queue.done.put(item)
|
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(
|
return web.json_response(
|
||||||
data=item.info,
|
data=item.info,
|
||||||
|
|
@ -309,7 +309,7 @@ async def item_archive_add(request: Request, queue: DownloadQueue, notify: Event
|
||||||
|
|
||||||
item.info.archive_status(force=True)
|
item.info.archive_status(force=True)
|
||||||
queue.done.put(item, no_notify=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(
|
return web.json_response(
|
||||||
data={"message": f"item '{item.info.title}' archived."},
|
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)
|
item.info.archive_status(force=True)
|
||||||
queue.done.put(item, no_notify=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(
|
return web.json_response(
|
||||||
data={"message": f"item '{item.info.title}' removed from archive."},
|
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.
|
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)
|
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,
|
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)
|
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()
|
queue.pause()
|
||||||
|
|
||||||
msg = "Non-active downloads have been paused."
|
msg = "Non-active downloads have been paused."
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.PAUSED,
|
Events.PAUSED,
|
||||||
data={"paused": True, "at": time.time()},
|
data={"paused": True, "at": time.time()},
|
||||||
title="Downloads Paused",
|
title="Downloads Paused",
|
||||||
|
|
@ -75,7 +75,7 @@ async def downloads_resume(queue: DownloadQueue, encoder: Encoder, notify: Event
|
||||||
queue.resume()
|
queue.resume()
|
||||||
|
|
||||||
msg = "Resumed all downloads."
|
msg = "Resumed all downloads."
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.RESUMED,
|
Events.RESUMED,
|
||||||
data={"paused": False, "at": time.time()},
|
data={"paused": False, "at": time.time()},
|
||||||
title="Downloads Resumed",
|
title="Downloads Resumed",
|
||||||
|
|
@ -109,7 +109,7 @@ async def shutdown_system(request: Request, config: Config, encoder: Encoder, no
|
||||||
app = request.app
|
app = request.app
|
||||||
|
|
||||||
async def do_shutdown():
|
async def do_shutdown():
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.SHUTDOWN,
|
Events.SHUTDOWN,
|
||||||
data={"app": app},
|
data={"app": app},
|
||||||
title="Application Shutdown",
|
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,
|
depth_limit=config.download_path_depth-1,
|
||||||
)
|
)
|
||||||
|
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.CONNECTED,
|
Events.CONNECTED,
|
||||||
data=data,
|
data=data,
|
||||||
title="Client connected",
|
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:
|
if not isinstance(data, str) or not data:
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.LOG_ERROR,
|
Events.LOG_ERROR,
|
||||||
title="Subscription Error",
|
title="Subscription Error",
|
||||||
message="Invalid event type was expecting a string.",
|
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):
|
async def add_url(queue: DownloadQueue, notify: EventBus, sid: str, data: dict):
|
||||||
data = data if isinstance(data, dict) else {}
|
data = data if isinstance(data, dict) else {}
|
||||||
if not (url := data.get("url", None)):
|
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
|
return
|
||||||
|
|
||||||
item: Item = Item.format(data)
|
item: Item = Item.format(data)
|
||||||
try:
|
try:
|
||||||
status = await queue.add(item=item)
|
status = await queue.add(item=item)
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
event=Events.ITEM_STATUS,
|
event=Events.ITEM_STATUS,
|
||||||
title="Adding URL",
|
title="Adding URL",
|
||||||
message=f"Adding URL '{url}' to the download queue.",
|
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:
|
except ValueError as e:
|
||||||
LOG.exception(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
|
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")
|
@route(RouteType.SOCKET, "item_cancel", "item_cancel")
|
||||||
async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str):
|
async def item_cancel(queue: DownloadQueue, notify: EventBus, sid: str, data: str):
|
||||||
if not (data := data if isinstance(data, str) else None):
|
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
|
return
|
||||||
|
|
||||||
await queue.cancel([data])
|
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 {}
|
data = data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
if not (id := data.get("id", None)):
|
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
|
return
|
||||||
|
|
||||||
await queue.clear([id], remove_file=bool(data.get("remove_file", False)))
|
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")
|
@route(RouteType.SOCKET, "item_start", "item_start")
|
||||||
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
|
async def item_start(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
|
||||||
if not data:
|
if not data:
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.LOG_ERROR,
|
Events.LOG_ERROR,
|
||||||
title="Invalid Request",
|
title="Invalid Request",
|
||||||
message="No items provided to start.",
|
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")
|
@route(RouteType.SOCKET, "item_pause", "item_pause")
|
||||||
async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
|
async def item_pause(queue: DownloadQueue, notify: EventBus, sid: str, data: list | str) -> None:
|
||||||
if not data:
|
if not data:
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.LOG_ERROR,
|
Events.LOG_ERROR,
|
||||||
title="Invalid Request",
|
title="Invalid Request",
|
||||||
message="No items provided to pause.",
|
message="No items provided to pause.",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
@route(RouteType.SOCKET, "cli_post", "socket_cli_post")
|
@route(RouteType.SOCKET, "cli_post", "socket_cli_post")
|
||||||
async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||||
if not config.console_enabled:
|
if not config.console_enabled:
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.LOG_ERROR,
|
Events.LOG_ERROR,
|
||||||
title="Feature disabled",
|
title="Feature disabled",
|
||||||
message="Console feature is disabled.",
|
message="Console feature is disabled.",
|
||||||
|
|
@ -26,7 +26,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||||
return
|
return
|
||||||
|
|
||||||
if not data:
|
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
|
return
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -93,7 +93,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||||
assert proc.stdout is not None
|
assert proc.stdout is not None
|
||||||
async for raw_line in proc.stdout:
|
async for raw_line in proc.stdout:
|
||||||
line = raw_line.rstrip(b"\n")
|
line = raw_line.rstrip(b"\n")
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.CLI_OUTPUT,
|
Events.CLI_OUTPUT,
|
||||||
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||||
to=sid,
|
to=sid,
|
||||||
|
|
@ -112,7 +112,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||||
|
|
||||||
if not chunk:
|
if not chunk:
|
||||||
if buffer:
|
if buffer:
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.CLI_OUTPUT,
|
Events.CLI_OUTPUT,
|
||||||
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
|
data={"type": "stdout", "line": buffer.decode("utf-8", errors="replace")},
|
||||||
to=sid,
|
to=sid,
|
||||||
|
|
@ -123,7 +123,7 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||||
*lines, buffer = buffer.split(b"\n")
|
*lines, buffer = buffer.split(b"\n")
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
await notify.emit(
|
notify.emit(
|
||||||
Events.CLI_OUTPUT,
|
Events.CLI_OUTPUT,
|
||||||
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
data={"type": "stdout", "line": line.decode("utf-8", errors="replace")},
|
||||||
to=sid,
|
to=sid,
|
||||||
|
|
@ -141,6 +141,6 @@ async def cli_post(config: Config, notify: EventBus, sid: str, data: str):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
|
LOG.error(f"CLI execute exception was thrown for client '{sid}'.")
|
||||||
LOG.exception(e)
|
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:
|
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"
|
target-version = "py311"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
installer = [
|
installer = ["pyinstaller"]
|
||||||
"pyinstaller",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||||
|
|
@ -92,6 +90,9 @@ installer = [
|
||||||
select = [
|
select = [
|
||||||
"ALL", # include all the rules, including new ones
|
"ALL", # include all the rules, including new ones
|
||||||
]
|
]
|
||||||
|
# Ignore some rules in test files.
|
||||||
|
per-file-ignores = { "test_*.py" = ["D"] }
|
||||||
|
|
||||||
ignore = [
|
ignore = [
|
||||||
#### modules
|
#### modules
|
||||||
"ANN", # flake8-annotations
|
"ANN", # flake8-annotations
|
||||||
|
|
@ -159,6 +160,7 @@ ignore = [
|
||||||
"LOG015",
|
"LOG015",
|
||||||
"PLC0415",
|
"PLC0415",
|
||||||
"S603",
|
"S603",
|
||||||
|
"D203",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Allow fix for all enabled rules (when `--fix`) is provided.
|
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||||
|
|
@ -200,3 +202,11 @@ docstring-code-line-length = "dynamic"
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
link-mode = "copy"
|
link-mode = "copy"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
testpaths = ["app/tests"]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[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="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
||||||
supported <NuxtLink target="_blank"
|
supported <NuxtLink target="_blank"
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
are ignored</NuxtLink>. Use with caution.
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
|
@ -454,7 +454,9 @@ const logic_test = computed(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return match_str(form.filter, test_data.value.data.data, true)
|
const st = match_str(form.filter, test_data.value.data.data)
|
||||||
|
console.log('Logic test:', st, form.filter, test_data.value.data.data)
|
||||||
|
return st
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
return false
|
return false
|
||||||
|
|
@ -503,7 +505,8 @@ const addExtra = (): void => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeExtra = (key: string): 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 => {
|
const updateExtraKey = (event: Event, oldKey: string): void => {
|
||||||
|
|
@ -528,8 +531,8 @@ const updateExtraKey = (event: Event, oldKey: string): void => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = form.extras[oldKey]
|
const value = form.extras[oldKey]
|
||||||
delete form.extras[oldKey]
|
const { [oldKey]: _, ...rest } = form.extras
|
||||||
form.extras[newKey] = value
|
form.extras = { ...rest, [newKey]: value }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -270,7 +270,7 @@ const validateItem = (item: DLField, index: number): boolean => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/^--[a-zA-Z0-9\-]+$/.test(item.field)) {
|
if (!/^--[a-zA-Z0-9-]+$/.test(item.field)) {
|
||||||
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
|
toast.error(`${item.name || index}: Invalid field format, it must start with '--' and contain no spaces.`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +278,7 @@ const validateItem = (item: DLField, index: number): boolean => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||||
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
const sortedDLFields = computed(() => items.value.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
watch(() => config.ytdlp_options, newOptions => ytDlpOptions.value = newOptions
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ const defaultTitle = computed(() => {
|
||||||
return 'Confirm'
|
return 'Confirm'
|
||||||
case 'prompt':
|
case 'prompt':
|
||||||
return 'Input required'
|
return 'Input required'
|
||||||
|
default:
|
||||||
|
return 'Dialog'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<vTooltip @show="loadContent" @hide="stopTimer">
|
<vTooltip @show="loadContent" @hide="stopTimer">
|
||||||
<slot />
|
<slot />
|
||||||
<template #popper class="p-0 m-0">
|
<template #popper>
|
||||||
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
||||||
<template v-else>
|
<div v-else>
|
||||||
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
||||||
<div class="is-block" style="word-break: all;" v-if="props.title">
|
<div class="is-block" style="word-break: all;" v-if="props.title">
|
||||||
<span style="font-size: 120%;">{{ props.title }}</span>
|
<span style="font-size: 120%;">{{ props.title }}</span>
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</vTooltip>
|
</vTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -35,7 +35,7 @@ const url = ref<string | null>(null)
|
||||||
const error = ref(false)
|
const error = ref(false)
|
||||||
const isPreloading = 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 cancelRequest = new AbortController()
|
||||||
|
|
||||||
const defaultLoader = async (): Promise<void> => {
|
const defaultLoader = async (): Promise<void> => {
|
||||||
|
|
|
||||||
|
|
@ -504,7 +504,7 @@ const dialog_confirm = ref<{
|
||||||
options: [],
|
options: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
const showThumbnails = computed(() => (props.thumbnails || true) && !hideThumbnail.value)
|
const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value)
|
||||||
|
|
||||||
const playVideo = (item: StoreItem) => { video_item.value = item }
|
const playVideo = (item: StoreItem) => { video_item.value = item }
|
||||||
const closeVideo = () => { video_item.value = null }
|
const closeVideo = () => { video_item.value = null }
|
||||||
|
|
@ -615,7 +615,7 @@ const deleteSelectedItems = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearCompleted = async () => {
|
const clearCompleted = async () => {
|
||||||
let msg = 'Clear all completed downloads?'
|
const msg = 'Clear all completed downloads?'
|
||||||
if (false === (await box.confirm(msg))) {
|
if (false === (await box.confirm(msg))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -774,7 +774,7 @@ const removeItem = async (item: StoreItem) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryItem = (item: StoreItem, re_add = false) => {
|
const retryItem = (item: StoreItem, re_add = false) => {
|
||||||
let item_req: Partial<StoreItem> = {
|
const item_req: Partial<StoreItem> = {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
preset: item.preset,
|
preset: item.preset,
|
||||||
folder: item.folder,
|
folder: item.folder,
|
||||||
|
|
@ -801,10 +801,12 @@ const retryItem = (item: StoreItem, re_add = false) => {
|
||||||
|
|
||||||
const pImg = (e: Event) => {
|
const pImg = (e: Event) => {
|
||||||
const target = e.target as HTMLImageElement
|
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) {
|
if (!bg_enable.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -823,7 +825,7 @@ const downloadSelected = async () => {
|
||||||
toast.error('No items selected.')
|
toast.error('No items selected.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let files_list: string[] = []
|
const files_list: string[] = []
|
||||||
for (const key in selectedElms.value) {
|
for (const key in selectedElms.value) {
|
||||||
const item_id = selectedElms.value[key]
|
const item_id = selectedElms.value[key]
|
||||||
if (!item_id) {
|
if (!item_id) {
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ function onIdle(cb: () => void): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
|
const { stop } = useIntersectionObserver(targetEl, (entries) => {
|
||||||
if (isIntersecting) {
|
const entry = entries[0]
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
if (unrenderTimer) clearTimeout(unrenderTimer)
|
if (unrenderTimer) clearTimeout(unrenderTimer)
|
||||||
|
|
||||||
renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0)
|
renderTimer = setTimeout(() => { shouldRender.value = true }, props.unrender ? 200 : 0)
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,13 @@
|
||||||
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="form.preset"
|
||||||
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command options for yt-dlp.' : ''">
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||||
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
|
<option v-for="cPreset in filter_presets(false)" :key="cPreset.name" :value="cPreset.name">
|
||||||
{{ item.name }}
|
{{ cPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Default presets">
|
<optgroup label="Default presets">
|
||||||
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
|
<option v-for="dPreset in filter_presets(true)" :key="dPreset.name" :value="dPreset.name">
|
||||||
{{ item.name }}
|
{{ dPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are supported
|
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are supported
|
||||||
<NuxtLink target="_blank"
|
<NuxtLink target="_blank"
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
are ignored</NuxtLink>. Use with caution.
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
|
@ -238,7 +238,6 @@ const props = defineProps<{ item?: Partial<item_request> }>()
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
|
(e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
|
||||||
(e: 'clear_form'): void
|
(e: 'clear_form'): void
|
||||||
(e: 'remove_archive', url: string): void
|
|
||||||
}>()
|
}>()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
const socket = useSocketStore()
|
const socket = useSocketStore()
|
||||||
|
|
@ -303,7 +302,7 @@ const addDownload = async () => {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyRegex = new RegExp(`(^|\s)${key}(\s|$)`);
|
const keyRegex = new RegExp(`(^|\\s)${key}(\\s|$)`);
|
||||||
if (form_cli && keyRegex.test(form_cli)) {
|
if (form_cli && keyRegex.test(form_cli)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -514,5 +513,6 @@ const getDefault = (type: 'cookies' | 'cli' | 'template' | 'folder', ret: string
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
|
||||||
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
const sortedDLFields = computed(() => config.dl_fields.sort((a, b) => (a.order || 0) - (b.order || 0)))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,8 @@
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
|
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
|
||||||
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item">
|
<option v-for="rMethod, index in requestMethods" :key="`${index}-${rMethod}`" :value="rMethod">
|
||||||
{{ item }}
|
{{ rMethod }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,8 +125,8 @@
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-fullwidth">
|
<div class="select is-fullwidth">
|
||||||
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
|
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
|
||||||
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item">
|
<option v-for="rType, index in requestType" :key="`${index}-${rType}`" :value="rType">
|
||||||
{{ ucFirst(item) }}
|
{{ ucFirst(rType) }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,8 +153,8 @@
|
||||||
<div class="control has-icons-left">
|
<div class="control has-icons-left">
|
||||||
<div class="select is-multiple is-fullwidth">
|
<div class="select is-multiple is-fullwidth">
|
||||||
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
|
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
|
||||||
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item">
|
<option v-for="aEvent, index in allowedEvents" :key="`${index}-${aEvent}`" :value="aEvent">
|
||||||
{{ item }}
|
{{ aEvent }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -183,13 +183,13 @@
|
||||||
<div class="select is-multiple is-fullwidth">
|
<div class="select is-multiple is-fullwidth">
|
||||||
<select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple>
|
<select id="on" class="is-fullwidth" v-model="form.presets" :disabled="addInProgress" multiple>
|
||||||
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
|
||||||
<option v-for="item in filter_presets(false)" :key="item.id" :value="item.name">
|
<option v-for="cPreset in filter_presets(false)" :key="cPreset.id" :value="cPreset.name">
|
||||||
{{ item.name }}
|
{{ cPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Default presets">
|
<optgroup label="Default presets">
|
||||||
<option v-for="item in filter_presets(true)" :key="item.id" :value="item.name">
|
<option v-for="dPreset in filter_presets(true)" :key="dPreset.id" :value="dPreset.name">
|
||||||
{{ item.name }}
|
{{ dPreset.name }}
|
||||||
</option>
|
</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -377,7 +377,7 @@ const checkInfo = async () => {
|
||||||
if (!isApprise.value) {
|
if (!isApprise.value) {
|
||||||
try {
|
try {
|
||||||
new URL(form.request.url)
|
new URL(form.request.url)
|
||||||
} catch (_) {
|
} catch {
|
||||||
toast.error('Invalid URL')
|
toast.error('Invalid URL')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
||||||
supported <NuxtLink target="_blank"
|
supported <NuxtLink target="_blank"
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
are ignored</NuxtLink>. Use with caution.
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
|
|
||||||
|
|
@ -427,7 +427,6 @@ const setStatus = (item: StoreItem): string => {
|
||||||
return 'Streaming'
|
return 'Streaming'
|
||||||
}
|
}
|
||||||
if ('preparing' === item.status) {
|
if ('preparing' === item.status) {
|
||||||
// @ts-ignore
|
|
||||||
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'
|
return ag(item, 'extras.external_downloader') ? 'External-DL' : 'Preparing..'
|
||||||
}
|
}
|
||||||
if (!item.status) {
|
if (!item.status) {
|
||||||
|
|
@ -494,7 +493,6 @@ const updateProgress = (item: StoreItem): string => {
|
||||||
return 'Post-processors are running.'
|
return 'Post-processors are running.'
|
||||||
}
|
}
|
||||||
if ('preparing' === item.status) {
|
if ('preparing' === item.status) {
|
||||||
// @ts-ignore
|
|
||||||
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
return ag(item, 'extras.external_downloader') ? 'External downloader.' : 'Preparing'
|
||||||
}
|
}
|
||||||
if (null != item.status) {
|
if (null != item.status) {
|
||||||
|
|
@ -547,7 +545,7 @@ const startItems = async () => {
|
||||||
if (1 > selectedElms.value.length) {
|
if (1 > selectedElms.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let filtered: string[] = []
|
const filtered: string[] = []
|
||||||
selectedElms.value.forEach(id => {
|
selectedElms.value.forEach(id => {
|
||||||
const item = stateStore.get('queue', id) as StoreItem
|
const item = stateStore.get('queue', id) as StoreItem
|
||||||
if (item && !item.auto_start && !item.status) {
|
if (item && !item.auto_start && !item.status) {
|
||||||
|
|
@ -569,7 +567,7 @@ const pauseSelected = async () => {
|
||||||
if (1 > selectedElms.value.length) {
|
if (1 > selectedElms.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let filtered: string[] = []
|
const filtered: string[] = []
|
||||||
selectedElms.value.forEach(id => {
|
selectedElms.value.forEach(id => {
|
||||||
const item = stateStore.get('queue', id) as StoreItem
|
const item = stateStore.get('queue', id) as StoreItem
|
||||||
if (item && item.auto_start && !item.status) {
|
if (item && item.auto_start && !item.status) {
|
||||||
|
|
@ -589,7 +587,9 @@ const pauseSelected = async () => {
|
||||||
|
|
||||||
const pImg = (e: Event) => {
|
const pImg = (e: Event) => {
|
||||||
const target = e.target as HTMLImageElement
|
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 => {
|
watch(embed_url, v => {
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ import { useStorage } from '@vueuse/core'
|
||||||
import { POSITION } from 'vue-toastification'
|
import { POSITION } from 'vue-toastification'
|
||||||
|
|
||||||
defineProps<{ isLoading: boolean }>()
|
defineProps<{ isLoading: boolean }>()
|
||||||
|
defineEmits<{ (e: 'reload_bg'): void }>()
|
||||||
|
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
<span>
|
<span>
|
||||||
<NuxtLink @click="showOptions = true" v-text="'View all options'" />. Not all options are
|
<NuxtLink @click="showOptions = true">View all options</NuxtLink>. Not all options are
|
||||||
supported <NuxtLink target="_blank"
|
supported <NuxtLink target="_blank"
|
||||||
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
to="https://github.com/arabcoders/ytptube/blob/master/app/library/Utils.py#L26">some
|
||||||
are ignored</NuxtLink>. Use with caution.
|
are ignored</NuxtLink>. Use with caution.
|
||||||
|
|
|
||||||
|
|
@ -238,14 +238,14 @@ onMounted(async () => {
|
||||||
sources.value.push({
|
sources.value.push({
|
||||||
src,
|
src,
|
||||||
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
type: allowedCodec ? response.mimetype : 'application/x-mpegURL',
|
||||||
onerror: (e: Event) => src_error(),
|
onerror: (_e: Event) => src_error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const src = makeDownload(config, props.item, 'api/download')
|
const src = makeDownload(config, props.item, 'api/download')
|
||||||
sources.value.push({
|
sources.value.push({
|
||||||
src,
|
src,
|
||||||
type: response.mimetype,
|
type: response.mimetype,
|
||||||
onerror: e => src_error(),
|
onerror: (_e: Event) => src_error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface notificationOptions {
|
||||||
force?: boolean,
|
force?: boolean,
|
||||||
closeOnClick?: boolean,
|
closeOnClick?: boolean,
|
||||||
position?: POSITION
|
position?: POSITION
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
onClick?: (closeToast: Function) => void
|
onClick?: (closeToast: Function) => void
|
||||||
store?: boolean,
|
store?: boolean,
|
||||||
lowPriority?: boolean
|
lowPriority?: boolean
|
||||||
|
|
@ -52,6 +53,7 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
|
||||||
|
|
||||||
opts.closeOnClick = toastDismissOnClick.value
|
opts.closeOnClick = toastDismissOnClick.value
|
||||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||||
opts.onClick = (closeToast: Function) => {
|
opts.onClick = (closeToast: Function) => {
|
||||||
if (opts?.closeOnClick !== false) {
|
if (opts?.closeOnClick !== false) {
|
||||||
closeToast()
|
closeToast()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<nav class="navbar is-mobile is-dark">
|
<nav class="navbar is-mobile is-dark">
|
||||||
|
|
||||||
<div class="navbar-brand pl-5">
|
<div class="navbar-brand pl-5">
|
||||||
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.native="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink class="navbar-item is-text-overflow" to="/" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
|
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
|
||||||
<span class="is-text-overflow">
|
<span class="is-text-overflow">
|
||||||
<span class="icon"><i class="fas fa-home" /></span>
|
<span class="icon"><i class="fas fa-home" /></span>
|
||||||
|
|
@ -25,30 +25,30 @@
|
||||||
|
|
||||||
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
<div class="navbar-menu is-unselectable" :class="{ 'is-active': showMenu }">
|
||||||
<div class="navbar-start" v-if="!config.app.basic_mode">
|
<div class="navbar-start" v-if="!config.app.basic_mode">
|
||||||
<NuxtLink class="navbar-item" to="/browser" @click.native="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink class="navbar-item" to="/browser" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
v-if="config.app.browser_enabled">
|
v-if="config.app.browser_enabled">
|
||||||
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
<span class="icon"><i class="fa-solid fa-folder-tree" /></span>
|
||||||
<span>Files</span>
|
<span>Files</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/presets" @click.native="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink class="navbar-item" to="/presets" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||||
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
<span class="icon"><i class="fa-solid fa-sliders" /></span>
|
||||||
<span>Presets</span>
|
<span>Presets</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/tasks" @click.native="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink class="navbar-item" to="/tasks" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||||
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
<span class="icon"><i class="fa-solid fa-tasks" /></span>
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/notifications" @click.native="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink class="navbar-item" to="/notifications" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||||
<span>Notifications</span>
|
<span>Notifications</span>
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/conditions" @click.native="(e: MouseEvent) => changeRoute(e)">
|
<NuxtLink class="navbar-item" to="/conditions" @click.prevent="(e: MouseEvent) => changeRoute(e)">
|
||||||
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
<span class="icon"><i class="fa-solid fa-filter" /></span>
|
||||||
<span>Conditions</span>
|
<span>Conditions</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
@ -62,13 +62,13 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<NuxtLink class="navbar-item" to="/logs" @click.native="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink class="navbar-item" to="/logs" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
v-if="config.app.file_logging">
|
v-if="config.app.file_logging">
|
||||||
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
<span class="icon"><i class="fa-solid fa-file-lines" /></span>
|
||||||
<span>Logs</span>
|
<span>Logs</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink class="navbar-item" to="/console" @click.native="(e: MouseEvent) => changeRoute(e)"
|
<NuxtLink class="navbar-item" to="/console" @click.prevent="(e: MouseEvent) => changeRoute(e)"
|
||||||
v-if="config.app.console_enabled">
|
v-if="config.app.console_enabled">
|
||||||
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
<span class="icon"><i class="fa-solid fa-terminal" /></span>
|
||||||
<span>Console</span>
|
<span>Console</span>
|
||||||
|
|
@ -251,7 +251,7 @@ const applyPreferredColorScheme = (scheme: string) => {
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await handleImage(bg_enable.value)
|
await handleImage(bg_enable.value)
|
||||||
} catch (e) { }
|
} catch { }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const opts = await request('/api/yt-dlp/options')
|
const opts = await request('/api/yt-dlp/options')
|
||||||
|
|
@ -260,11 +260,11 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
const data: Array<YTDLPOption> = await opts.json()
|
const data: Array<YTDLPOption> = await opts.json()
|
||||||
config.ytdlp_options = data
|
config.ytdlp_options = data
|
||||||
} catch (e) { }
|
} catch { }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
applyPreferredColorScheme(selectedTheme.value)
|
applyPreferredColorScheme(selectedTheme.value)
|
||||||
} catch (e) { }
|
} catch { }
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTheme, value => {
|
watch(selectedTheme, value => {
|
||||||
|
|
@ -274,7 +274,7 @@ watch(selectedTheme, value => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyPreferredColorScheme(value)
|
applyPreferredColorScheme(value)
|
||||||
} catch (e) { }
|
} catch { }
|
||||||
})
|
})
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload()
|
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
|
showMenu.value = false
|
||||||
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
|
document.querySelectorAll('div.has-dropdown').forEach(el => el.classList.remove('is-active'))
|
||||||
if (callback) {
|
if (callback) {
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@ const show_filter = ref<boolean>(false)
|
||||||
const reset_dialog = () => ({
|
const reset_dialog = () => ({
|
||||||
visible: false,
|
visible: false,
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
confirm: (opts: any) => { },
|
confirm: (_opts: any) => { },
|
||||||
message: '',
|
message: '',
|
||||||
html_message: '',
|
html_message: '',
|
||||||
options: [],
|
options: [],
|
||||||
|
|
@ -317,8 +317,8 @@ const sortedItems = (items: FileItem[]): FileItem[] => {
|
||||||
|
|
||||||
if (sort_by.value === 'date') {
|
if (sort_by.value === 'date') {
|
||||||
return items.sort((a, b) => {
|
return items.sort((a, b) => {
|
||||||
let aDate = new Date(a.mtime)
|
const aDate = new Date(a.mtime)
|
||||||
let bDate = new Date(b.mtime)
|
const bDate = new Date(b.mtime)
|
||||||
return 'asc' === sort_order.value ? aDate.getTime() - bDate.getTime() : bDate.getTime() - aDate.getTime()
|
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(/\/+$/, '')
|
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||||
|
|
||||||
let links = []
|
const links = []
|
||||||
links.push({
|
links.push({
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
link: baseLink,
|
link: baseLink,
|
||||||
|
|
@ -487,9 +487,9 @@ const makeBreadCrumb = (path: string): { name: string, link: string, path: strin
|
||||||
})
|
})
|
||||||
|
|
||||||
// -- explode path and create links
|
// -- explode path and create links
|
||||||
let parts = path.split('/')
|
const parts = path.split('/')
|
||||||
parts.forEach((part, index) => {
|
parts.forEach((part, index) => {
|
||||||
let path = baseLink + parts.slice(0, index + 1).join('/')
|
const path = baseLink + parts.slice(0, index + 1).join('/')
|
||||||
links.push({
|
links.push({
|
||||||
name: part,
|
name: part,
|
||||||
link: path,
|
link: path,
|
||||||
|
|
@ -570,12 +570,12 @@ const createDirectory = async (dir: string): Promise<void> => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_dir = sTrim(newDir, '/')
|
const new_dir = sTrim(newDir, '/')
|
||||||
if (!new_dir || new_dir === dir) {
|
if (!new_dir || new_dir === dir) {
|
||||||
return
|
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)
|
reloadContent(path.value, true)
|
||||||
toast.success(`Successfully created '${new_dir}'.`)
|
toast.success(`Successfully created '${new_dir}'.`)
|
||||||
})
|
})
|
||||||
|
|
@ -598,7 +598,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_name = newName.trim()
|
const new_name = newName.trim()
|
||||||
if (!new_name || new_name === item.name) {
|
if (!new_name || new_name === item.name) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -625,7 +625,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
return
|
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)
|
items.value = items.value.filter(i => i.path !== item.path)
|
||||||
toast.warning(`Deleted '${item.name}'.`)
|
toast.warning(`Deleted '${item.name}'.`)
|
||||||
})
|
})
|
||||||
|
|
@ -646,7 +646,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_path = sTrim(newPath, '/') || '/'
|
const new_path = sTrim(newPath, '/') || '/'
|
||||||
if (!new_path || new_path === item.path) {
|
if (!new_path || new_path === item.path) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@
|
||||||
|
|
||||||
<NewDownload v-if="config.showForm || config.app.basic_mode"
|
<NewDownload v-if="config.showForm || config.app.basic_mode"
|
||||||
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
@getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||||
:item="item_form" @clear_form="item_form = {}" @remove_archive="" />
|
:item="item_form" @clear_form="item_form = {}" />
|
||||||
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
<Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
|
||||||
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
:thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
|
||||||
@clear_search="query = ''" />
|
@clear_search="query = ''" />
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@
|
||||||
<td class="is-text-overflow is-vcentered">
|
<td class="is-text-overflow is-vcentered">
|
||||||
<div class="is-bold">
|
<div class="is-bold">
|
||||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-unselectable">
|
<div class="is-unselectable">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
</p>
|
</p>
|
||||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||||
<span>{{item.request.headers.map(h => h.key).join(', ')}}</span>
|
<span>{{ item.request.headers.map(h => h.key).join(', ') }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -403,10 +403,11 @@ const exportItem = async (item: notification) => {
|
||||||
_version: '1.0',
|
_version: '1.0',
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = ['id', 'raw']
|
const keys = ['id', 'raw'] as const
|
||||||
keys.forEach(k => {
|
keys.forEach(k => {
|
||||||
if (Object.prototype.hasOwnProperty.call(data, 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) {
|
for (const key of keys) {
|
||||||
if (key in data) {
|
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 isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
|
||||||
const tasks = ref<Array<task_item>>([])
|
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 taskRef = ref<string>('')
|
||||||
const toggleForm = ref<boolean>(false)
|
const toggleForm = ref<boolean>(false)
|
||||||
const isLoading = ref<boolean>(true)
|
const isLoading = ref<boolean>(true)
|
||||||
|
|
@ -409,7 +409,7 @@ const table_container = ref(false)
|
||||||
const reset_dialog = () => ({
|
const reset_dialog = () => ({
|
||||||
visible: false,
|
visible: false,
|
||||||
title: 'Confirm Action',
|
title: 'Confirm Action',
|
||||||
confirm: (opts: any) => { },
|
confirm: (_opts: any) => { },
|
||||||
message: '',
|
message: '',
|
||||||
html_message: '',
|
html_message: '',
|
||||||
options: [],
|
options: [],
|
||||||
|
|
@ -649,7 +649,7 @@ onMounted(async () => {
|
||||||
const tryParse = (expression: string) => {
|
const tryParse = (expression: string) => {
|
||||||
try {
|
try {
|
||||||
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
|
return moment(CronExpressionParser.parse(expression).next().toISOString()).fromNow()
|
||||||
} catch (e) {
|
} catch {
|
||||||
return "Invalid"
|
return "Invalid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -706,7 +706,7 @@ const runNow = async (item: task_item, mass: boolean = false) => {
|
||||||
item.in_progress = true
|
item.in_progress = true
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = {
|
const data = {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
preset: item.preset,
|
preset: item.preset,
|
||||||
} as task_item
|
} as task_item
|
||||||
|
|
@ -754,7 +754,7 @@ const statusHandler = async (stream: string) => {
|
||||||
const exportItem = async (item: task_item) => {
|
const exportItem = async (item: task_item) => {
|
||||||
const info = JSON.parse(JSON.stringify(item))
|
const info = JSON.parse(JSON.stringify(item))
|
||||||
|
|
||||||
let data = {
|
const data = {
|
||||||
name: info.name,
|
name: info.name,
|
||||||
url: info.url,
|
url: info.url,
|
||||||
preset: info.preset,
|
preset: info.preset,
|
||||||
|
|
@ -780,7 +780,7 @@ const exportItem = async (item: task_item) => {
|
||||||
const get_tags = (name: string): Array<string> => {
|
const get_tags = (name: string): Array<string> => {
|
||||||
const regex = /\[(.*?)\]/g;
|
const regex = /\[(.*?)\]/g;
|
||||||
const matches = name.match(regex);
|
const matches = name.match(regex);
|
||||||
return !matches ? [] : matches.map(tag => tag.replace(/[\[\]]/g, '').trim());
|
return !matches ? [] : matches.map(tag => tag.replace(/[[\]]/g, '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { io } from "socket.io-client";
|
import { io, type Socket as IOSocket, type SocketOptions, type ManagerOptions } from "socket.io-client"
|
||||||
import type { Socket as IOSocket, SocketOptions } from "socket.io-client"
|
|
||||||
import type { ManagerOptions } from "socket.io-client";
|
|
||||||
import type { ConfigState } from "~/types/config";
|
import type { ConfigState } from "~/types/config";
|
||||||
import type { StoreItem } from "~/types/store";
|
import type { StoreItem } from "~/types/store";
|
||||||
|
|
||||||
|
|
@ -29,7 +27,7 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
let opts = {
|
const opts = {
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
} as Partial<ManagerOptions & SocketOptions>
|
} as Partial<ManagerOptions & SocketOptions>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ export const useStateStore = defineStore('state', () => {
|
||||||
|
|
||||||
const remove = (type: StateType, key: KeyType): void => {
|
const remove = (type: StateType, key: KeyType): void => {
|
||||||
if (state[type][key]) {
|
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 => {
|
const move = (fromType: StateType, toType: StateType, key: KeyType): void => {
|
||||||
if (state[fromType][key]) {
|
if (state[fromType][key]) {
|
||||||
state[toType][key] = 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;
|
icon?: string;
|
||||||
|
|
||||||
/** The order of the field, used to sort the fields in the UI. */
|
/** 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 */
|
/** The default value of the field, It's currently unused */
|
||||||
value: string;
|
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 = {
|
export type Event = {
|
||||||
id: str
|
id: string
|
||||||
created_at: str
|
created_at: string
|
||||||
event: str
|
event: string
|
||||||
title: str | null = null
|
title: string | null
|
||||||
message: str | null = null
|
message: string | null
|
||||||
data: Any = { }
|
data: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const sources: EmbedSource[] = [
|
||||||
{
|
{
|
||||||
name: 'youtube',
|
name: 'youtube',
|
||||||
url: 'https://www.youtube-nocookie.com/embed/{id}',
|
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",
|
name: "instagram_post",
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,7 @@ const request = (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||||
* @returns A string without ANSI escape sequences.
|
* @returns A string without ANSI escape sequences.
|
||||||
*/
|
*/
|
||||||
const removeANSIColors = (text: string): string => {
|
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
|
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.
|
* @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) => {
|
const awaiter = async (test: Function, timeout_ms: number = 20 * 1000, frequency: number = 200) => {
|
||||||
if (typeof (test) != "function") {
|
if (typeof (test) != "function") {
|
||||||
throw new Error("test should be a function in awaiter(test, [timeout_ms], [frequency])")
|
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+)?/;
|
const NUMBER_RE = /\d+(?:\.\d+)?/;
|
||||||
|
|
||||||
function match_str(filterStr: string, dct: Record<string, any>, incomplete: boolean | Set<string> = false): boolean {
|
|
||||||
if (!filterStr) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/\s*&\s*$/.test(filterStr)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filterParts = filterStr
|
|
||||||
.split(/(?:(^|[^\\]))&/g)
|
|
||||||
.reduce((parts, part, index, array) => {
|
|
||||||
if (index % 2 === 0) {
|
|
||||||
parts.push((part + (array[index + 1] || '')).replace(/\\&/g, '&'));
|
|
||||||
}
|
|
||||||
return parts;
|
|
||||||
}, [] as string[]);
|
|
||||||
|
|
||||||
if (filterParts.some(fp => !fp.trim())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filterParts.every(filterPart => _match_one(filterPart, dct, incomplete));
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null {
|
function lookup_unit_table(unitTable: Record<string, number>, s: string, strict = false): number | null {
|
||||||
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
|
const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/;
|
||||||
const unitsRe = Object.keys(unitTable).map(u => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
const unitsRe = Object.keys(unitTable)
|
||||||
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? '' : 'i');
|
.map(u => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||||
|
.join("|");
|
||||||
|
const re = new RegExp(`^(?<num>${numRe.source})\\s*(?<unit>${unitsRe})\\b`, strict ? "" : "i");
|
||||||
|
|
||||||
const m = s.match(re);
|
const m = s.match(re);
|
||||||
if (!m?.groups?.num || !m.groups.unit) {
|
if (!m?.groups?.num || !m.groups.unit) {
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const numStr = m.groups.num.replace(',', '.');
|
const numStr = m.groups.num.replace(",", ".");
|
||||||
const unit = m.groups.unit;
|
const unit = m.groups.unit;
|
||||||
const mult = unitTable[unit];
|
const mult = unitTable[unit];
|
||||||
if (undefined === mult) {
|
if (undefined === mult) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.round(parseFloat(numStr) * mult);
|
return Math.round(parseFloat(numStr) * mult);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse_filesize(s: string | null): number | null {
|
function parse_filesize(s: string | null): number | null {
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
|
|
||||||
const UNIT_TABLE: Record<string, number> = {
|
const UNIT_TABLE: Record<string, number> = {
|
||||||
B: 1, bytes: 1, b: 1,
|
B: 1,
|
||||||
|
b: 1,
|
||||||
|
bytes: 1,
|
||||||
KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024,
|
KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024,
|
||||||
MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2,
|
MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2,
|
||||||
GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3,
|
GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3,
|
||||||
TiB: 1024 ** 4, TB: 1000 ** 4, terabytes: 1000 ** 4, tebibytes: 1024 ** 4,
|
TiB: 1024 ** 4, TB: 1000 ** 4, terabytes: 1000 ** 4, tebibytes: 1024 ** 4,
|
||||||
|
PiB: 1024 ** 5, PB: 1000 ** 5, petabytes: 1000 ** 5, pebibytes: 1024 ** 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
return lookup_unit_table(UNIT_TABLE, s);
|
return lookup_unit_table(UNIT_TABLE, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse_duration(s: string): number | null {
|
function parse_duration(s: string): number | null {
|
||||||
if (!s.trim()) {
|
if (!s?.trim()) return null;
|
||||||
return null;
|
|
||||||
|
// Support ISO-ish short forms: "2m", "10s", "1h", "1d"
|
||||||
|
const simpleRe = /^(?<num>\d+(?:\.\d+)?)(?<unit>[smhd])$/i;
|
||||||
|
const sm = s.match(simpleRe);
|
||||||
|
if (sm?.groups?.num && sm.groups.unit) {
|
||||||
|
const num = parseFloat(sm.groups.num);
|
||||||
|
switch (sm.groups.unit.toLowerCase()) {
|
||||||
|
case "s": return num;
|
||||||
|
case "m": return num * 60;
|
||||||
|
case "h": return num * 3600;
|
||||||
|
case "d": return num * 86400;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
|
// Support hh:mm:ss[.ms]
|
||||||
const m = s.match(durationRe);
|
const hmsRe = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\d+)?Z?$/;
|
||||||
if (!m?.groups) {
|
const m = s.match(hmsRe);
|
||||||
return null;
|
if (m?.groups) {
|
||||||
|
const { days, hours, mins, secs, ms } = m.groups;
|
||||||
|
return (
|
||||||
|
(parseFloat(days ?? "0") * 86400) +
|
||||||
|
(parseFloat(hours ?? "0") * 3600) +
|
||||||
|
(parseFloat(mins ?? "0") * 60) +
|
||||||
|
parseFloat(secs ?? "0") +
|
||||||
|
parseFloat((ms ?? "0").replace(":", "."))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { days, hours, mins, secs, ms } = m.groups;
|
return null;
|
||||||
|
|
||||||
return (
|
|
||||||
(parseFloat(days ?? '0') * 86400) +
|
|
||||||
(parseFloat(hours ?? '0') * 3600) +
|
|
||||||
(parseFloat(mins ?? '0') * 60) + parseFloat(secs ?? '0') + parseFloat((ms ?? '0').replace(':', '.'))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _match_one(filterPart: string, dct: Record<string, any>, incomplete: boolean | Set<string>): boolean {
|
|
||||||
|
// ---------- Match One ----------
|
||||||
|
function _match_one(filterPart: string, dct: Record<string, any>): boolean {
|
||||||
const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = {
|
const STRING_OPERATORS: Record<string, (a: string, b: string) => boolean> = {
|
||||||
'*=': (a, b) => a.includes(b),
|
"*=": (a, b) => a.includes(b),
|
||||||
'^=': (a, b) => a.startsWith(b),
|
"^=": (a, b) => a.startsWith(b),
|
||||||
'$=': (a, b) => a.endsWith(b),
|
"$=": (a, b) => a.endsWith(b),
|
||||||
'~=': (a, b) => new RegExp(b).test(a),
|
"~=": (a, b) => new RegExp(b).test(a),
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
|
const COMPARISON_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
|
||||||
'*=': STRING_OPERATORS['*=']!,
|
"*=": STRING_OPERATORS["*="]!,
|
||||||
'^=': STRING_OPERATORS['^=']!,
|
"^=": STRING_OPERATORS["^="]!,
|
||||||
'$=': STRING_OPERATORS['$=']!,
|
"$=": STRING_OPERATORS["$="]!,
|
||||||
'~=': STRING_OPERATORS['~=']!,
|
"~=": STRING_OPERATORS["~="]!,
|
||||||
'<=': (a, b) => a <= b,
|
"<=": (a, b) => a <= b,
|
||||||
'<': (a, b) => a < b,
|
"<": (a, b) => a < b,
|
||||||
'>=': (a, b) => a >= b,
|
">=": (a, b) => a >= b,
|
||||||
'>': (a, b) => a > b,
|
">": (a, b) => a > b,
|
||||||
'=': (a, b) => a == b,
|
"=": (a, b) => a == b,
|
||||||
|
};
|
||||||
|
const UNARY_OPERATORS: Record<string, (v: any) => boolean> = {
|
||||||
|
"": v => (typeof v === "boolean" ? v === true : v != null),
|
||||||
|
"!": v => (typeof v === "boolean" ? v === false : v == null),
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIncomplete = typeof incomplete === 'boolean' ? (_: string) => incomplete : (k: string) => incomplete.has(k);
|
const cmpOps = Object.keys(COMPARISON_OPERATORS)
|
||||||
|
.map(op => op.replace(/([.*+?^${}()|[\]\\])/g, "\\$1"))
|
||||||
|
.join("|");
|
||||||
|
|
||||||
const cmpOps = Object.keys(COMPARISON_OPERATORS).map(op => op.replace(/([.*+?^${}()|\[\]\\])/g, '\\$1')).join('|');
|
const operatorRe = new RegExp(
|
||||||
const operatorRe = new RegExp(`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+))$`);
|
`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+?))?$`
|
||||||
|
);
|
||||||
|
|
||||||
const m = filterPart.trim().match(operatorRe);
|
const m = filterPart.trim().match(operatorRe);
|
||||||
if (m?.groups) {
|
if (m?.groups) {
|
||||||
const { key, negation, op, noneInclusive, quote, quoted, plain } = m.groups;
|
const { key, negation, op, quote, quoted, plain } = m.groups;
|
||||||
const unnegatedOp = COMPARISON_OPERATORS[op!];
|
const unnegatedOp = COMPARISON_OPERATORS[op!];
|
||||||
if (!unnegatedOp) {
|
if (!unnegatedOp) throw new Error(`Invalid operator '${op}'`);
|
||||||
throw new Error(`Invalid operator '${op}'`);
|
|
||||||
|
// Handle incomplete expressions (missing value)
|
||||||
|
const value = quoted ?? plain ?? "";
|
||||||
|
if (!value && op !== "=" && op !== "!") {
|
||||||
|
return false; // Incomplete non-equality expression
|
||||||
}
|
}
|
||||||
|
|
||||||
const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
|
const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp;
|
||||||
|
let processedValue = value;
|
||||||
let value = quoted ?? plain ?? '';
|
if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, "g"), quote);
|
||||||
|
|
||||||
if (quote) {
|
|
||||||
value = value.replace(new RegExp(`\\\\${quote}`, 'g'), quote);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(key! in dct)) {
|
|
||||||
return isIncomplete(key!) || Boolean(noneInclusive);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actual = dct[key!];
|
const actual = dct[key!];
|
||||||
let numeric: number | null = null;
|
if (actual === undefined || actual === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof actual === 'number') {
|
let numeric: number | null = null;
|
||||||
numeric = Number(value);
|
// Try to convert comparison value to numeric first
|
||||||
if (Number.isNaN(numeric)) {
|
numeric = Number(processedValue);
|
||||||
numeric = parse_filesize(value) ?? parse_filesize(`${value}B`) ?? parse_duration(value);
|
if (Number.isNaN(numeric)) {
|
||||||
|
numeric = parse_filesize(processedValue) ?? parse_filesize(`${processedValue}B`) ?? parse_duration(processedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try to convert actual value to numeric if we have a numeric comparison
|
||||||
|
let finalActual = actual;
|
||||||
|
if (numeric !== null && typeof actual === "string") {
|
||||||
|
const numericActual = Number(actual);
|
||||||
|
if (!Number.isNaN(numericActual)) {
|
||||||
|
finalActual = numericActual;
|
||||||
|
} else {
|
||||||
|
const parsedActual = parse_filesize(actual) ?? parse_duration(actual);
|
||||||
|
if (parsedActual !== null) {
|
||||||
|
finalActual = parsedActual;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (numeric !== null && op! in STRING_OPERATORS) {
|
if (numeric !== null && op! in STRING_OPERATORS) {
|
||||||
throw new Error(`Operator ${op} only supports string values!`);
|
throw new Error(`Operator ${op} only supports string values!`);
|
||||||
}
|
}
|
||||||
|
return opFn(finalActual, numeric !== null ? numeric : processedValue);
|
||||||
return opFn(actual, numeric !== null ? numeric : value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNARY_OPERATORS: Record<string, (v: any) => boolean> = {
|
// unary operator
|
||||||
'': v => typeof v === 'boolean' ? v === true : v != null,
|
const unaryRe = /^(?<op>!?)\s*(?<key>[a-z_]+)$/;
|
||||||
'!': v => typeof v === 'boolean' ? v === false : v == null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const unaryRe = new RegExp(`^(?<op>!?)\\s*(?<key>[a-z_]+)$`);
|
|
||||||
const mu = filterPart.trim().match(unaryRe);
|
const mu = filterPart.trim().match(unaryRe);
|
||||||
if (mu?.groups) {
|
if (mu?.groups) {
|
||||||
const { op, key } = mu.groups;
|
const { op, key } = mu.groups;
|
||||||
|
|
||||||
if (!(key! in dct)) {
|
|
||||||
return isIncomplete(key!);
|
|
||||||
}
|
|
||||||
|
|
||||||
const actual = dct[key!];
|
const actual = dct[key!];
|
||||||
const unaryOp = UNARY_OPERATORS[op!];
|
return UNARY_OPERATORS[op!]!(actual);
|
||||||
|
|
||||||
if (!unaryOp) {
|
|
||||||
throw new Error(`Invalid unary operator '${op}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return unaryOp(actual);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Invalid filter part '${filterPart}'`);
|
throw new Error(`Invalid filter part '${filterPart}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { match_str, lookup_unit_table, parse_filesize, parse_duration, _match_one }
|
// ---------- Parser ----------
|
||||||
|
type Node = ["ATOM", string] | ["AND", Node, Node] | ["OR", Node, Node];
|
||||||
|
|
||||||
|
class MatchFilterParser {
|
||||||
|
expr: string;
|
||||||
|
tokens: [string, string][];
|
||||||
|
pos: number;
|
||||||
|
ast: Node;
|
||||||
|
|
||||||
|
constructor(expr: string) {
|
||||||
|
this.expr = expr;
|
||||||
|
this.tokens = this._tokenize(expr);
|
||||||
|
this.pos = 0;
|
||||||
|
this.ast = this._parseOr();
|
||||||
|
}
|
||||||
|
|
||||||
|
static run(expr: string, dct: Record<string, any> = {}): boolean {
|
||||||
|
return new MatchFilterParser(expr).evaluate(dct);
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate(dct: Record<string, any>): boolean {
|
||||||
|
return this._eval(this.ast, dct);
|
||||||
|
}
|
||||||
|
|
||||||
|
export(): string[] {
|
||||||
|
return this._export(this.ast).map(parts => parts.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _tokenize(expr: string): [string, string][] {
|
||||||
|
// First, let's normalize spaces around operators to make parsing easier
|
||||||
|
// Replace spaced operators with non-spaced ones
|
||||||
|
let normalizedExpr = expr;
|
||||||
|
const operators = ["<=", ">=", "<", ">", "\\*=", "\\^=", "\\$=", "~=", "="];
|
||||||
|
for (const op of operators) {
|
||||||
|
// Replace "key op value" with "keyopvalue" (removing extra spaces)
|
||||||
|
const pattern = new RegExp(`([a-z_]+)\\s*(!?)\\s*(${op})\\s*`, "g");
|
||||||
|
normalizedExpr = normalizedExpr.replace(pattern, "$1$2$3");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenSpec: [RegExp, string | null][] = [
|
||||||
|
[/\|\|/, "OR"],
|
||||||
|
[/&/, "AND"],
|
||||||
|
[/\(/, "LPAREN"],
|
||||||
|
[/\)/, "RPAREN"],
|
||||||
|
[/[^\s&|()]+/, "ATOM"],
|
||||||
|
[/\s+/, null],
|
||||||
|
];
|
||||||
|
const regex = new RegExp(
|
||||||
|
tokenSpec.map(([pat, _name], i) => `(?<T${i}>${pat.source})`).join("|"),
|
||||||
|
"g"
|
||||||
|
);
|
||||||
|
const tokens: [string, string][] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = regex.exec(normalizedExpr)) !== null) {
|
||||||
|
for (let i = 0; i < tokenSpec.length; i++) {
|
||||||
|
if (m.groups?.[`T${i}`]) {
|
||||||
|
const tokenSpecEntry = tokenSpec[i];
|
||||||
|
if (tokenSpecEntry) {
|
||||||
|
const kind = tokenSpecEntry[1];
|
||||||
|
if (kind) tokens.push([kind, m[0]]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _accept(kind: string): boolean {
|
||||||
|
if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) {
|
||||||
|
this.pos++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _expect(kind: string): [string, string] {
|
||||||
|
if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) {
|
||||||
|
return this.tokens[this.pos++]!;
|
||||||
|
}
|
||||||
|
throw new SyntaxError(`Expected ${kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseOr(): Node {
|
||||||
|
let left = this._parseAnd();
|
||||||
|
while (this._accept("OR")) {
|
||||||
|
const right = this._parseAnd();
|
||||||
|
left = ["OR", left, right];
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseAnd(): Node {
|
||||||
|
let left = this._parseAtom();
|
||||||
|
while (this._accept("AND")) {
|
||||||
|
const right = this._parseAtom();
|
||||||
|
left = ["AND", left, right];
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseAtom(): Node {
|
||||||
|
if (this._accept("LPAREN")) {
|
||||||
|
const node = this._parseOr();
|
||||||
|
this._expect("RPAREN");
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const tok = this._expect("ATOM");
|
||||||
|
return ["ATOM", tok[1].trim()];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _eval(node: Node, dct: Record<string, any>): boolean {
|
||||||
|
if (node[0] === "ATOM") {
|
||||||
|
return _match_one(node[1], dct);
|
||||||
|
}
|
||||||
|
if (node[0] === "AND") {
|
||||||
|
return this._eval(node[1], dct) && this._eval(node[2], dct);
|
||||||
|
}
|
||||||
|
if (node[0] === "OR") {
|
||||||
|
return this._eval(node[1], dct) || this._eval(node[2], dct);
|
||||||
|
}
|
||||||
|
throw new Error("Invalid AST node " + node[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _export(node: Node): string[][] {
|
||||||
|
if (node[0] === "ATOM") return [[node[1]]];
|
||||||
|
if (node[0] === "AND") {
|
||||||
|
const left = this._export(node[1]);
|
||||||
|
const right = this._export(node[2]);
|
||||||
|
return left.flatMap(l => right.map(r => [...l, ...r]));
|
||||||
|
}
|
||||||
|
if (node[0] === "OR") {
|
||||||
|
return [...this._export(node[1]), ...this._export(node[2])];
|
||||||
|
}
|
||||||
|
throw new Error("Invalid AST node " + node[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const match_str = (expr: string, dct: Record<string, any>): boolean => MatchFilterParser.run(expr, dct)
|
||||||
|
|
||||||
|
export { MatchFilterParser, match_str }
|
||||||
|
|
|
||||||
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({
|
export default defineNuxtConfig({
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -60,6 +59,7 @@ export default defineNuxtConfig({
|
||||||
'@pinia/nuxt',
|
'@pinia/nuxt',
|
||||||
'@vueuse/nuxt',
|
'@vueuse/nuxt',
|
||||||
'floating-vue/nuxt',
|
'floating-vue/nuxt',
|
||||||
|
process.env.NODE_ENV === 'development' ? '@nuxt/eslint' : '',
|
||||||
],
|
],
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
|
|
@ -76,6 +76,6 @@ export default defineNuxtConfig({
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
compatibilityDate: "2025-08-03",
|
compatibilityDate: "2025-08-03",
|
||||||
experimental: {
|
experimental: {
|
||||||
checkOutdatedBuildInterval: 1000 * 60 * 60,
|
checkOutdatedBuildInterval: 1000 * 60 * 10,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@
|
||||||
"dev": "nuxt dev --host",
|
"dev": "nuxt dev --host",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare",
|
||||||
|
"typecheck": "nuxt typecheck",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"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",
|
"web-types": "./web-types.json",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -17,9 +24,9 @@
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"cron-parser": "^5.3.1",
|
"cron-parser": "^5.3.1",
|
||||||
"cronstrue": "^3.2.0",
|
"cronstrue": "^3.3.0",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"hls.js": "^1.6.11",
|
"hls.js": "^1.6.12",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nuxt": "^4.1.1",
|
"nuxt": "^4.1.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
|
@ -39,5 +46,15 @@
|
||||||
"strip-ansi": "7.1.0",
|
"strip-ansi": "7.1.0",
|
||||||
"ansi-styles": "6.2.0",
|
"ansi-styles": "6.2.0",
|
||||||
"ansi-regex": "6.2.0"
|
"ansi-regex": "6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/eslint": "^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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "macholib"
|
name = "macholib"
|
||||||
version = "1.16.3"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.3.2"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "pyinstaller"
|
name = "pyinstaller"
|
||||||
version = "6.15.0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dateutil"
|
name = "python-dateutil"
|
||||||
version = "2.9.0.post0"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "80.9.0"
|
version = "80.9.0"
|
||||||
|
|
@ -1430,6 +1511,13 @@ installer = [
|
||||||
{ name = "pyinstaller" },
|
{ name = "pyinstaller" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiocron", specifier = ">=1.8" },
|
{ name = "aiocron", specifier = ">=1.8" },
|
||||||
|
|
@ -1466,6 +1554,13 @@ requires-dist = [
|
||||||
]
|
]
|
||||||
provides-extras = ["installer"]
|
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]]
|
[[package]]
|
||||||
name = "zipstream-ng"
|
name = "zipstream-ng"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue