From 3706e63beddeeccc1b5809fdb4677f9f2ba49ca6 Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Wed, 21 Jan 2026 11:14:28 -0600 Subject: [PATCH 01/63] feat: Add DISPATCHARR_ENV=modular support for multi-container deployments - docker/entrypoint.sh: Conditional PostgreSQL init/startup for modular mode - docker/entrypoint.celery.sh: New dedicated entrypoint for celery container - prevents race condition in modular deployment - docker/uwsgi.modular.ini: New uWSGI config without Redis/Celery daemons - docker/Dockerfile: Add line ending fixes and chmod for entrypoints - adds cross-platform support for image builds - docker/docker-compose.yml: Configure modular mode with DISPATCHARR_ENV - uses new celery entrypoint and adds depends_on to prevent race condition --- docker/Dockerfile | 4 +++ docker/docker-compose.yml | 19 +++++++----- docker/entrypoint.celery.sh | 22 ++++++++++++++ docker/entrypoint.sh | 52 ++++++++++++++++++++++---------- docker/uwsgi.modular.ini | 59 +++++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 23 deletions(-) create mode 100644 docker/entrypoint.celery.sh create mode 100644 docker/uwsgi.modular.ini diff --git a/docker/Dockerfile b/docker/Dockerfile index bfb35c11..409f1096 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,6 +30,10 @@ WORKDIR /app COPY . /app # Copy nginx configuration COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default +# Verify entrypoint scripts exist, fix line endings, and make them executable +RUN ls -la /app/docker/entrypoint*.sh && \ + sed -i 's/\r$//' /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh && \ + chmod +x /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh # Clean out existing frontend folder RUN rm -rf /app/frontend # Copy built frontend assets diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e4093e4b..df295f90 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -10,6 +10,7 @@ services: - db - redis environment: + - DISPATCHARR_ENV=modular - POSTGRES_HOST=db - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch @@ -25,7 +26,6 @@ services: # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) # Negative values require cap_add: SYS_NICE (uncomment below) #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) - #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority) # # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) #cap_add: @@ -52,22 +52,27 @@ services: depends_on: - db - redis + - web volumes: - - ../:/app + - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" environment: + - DISPATCHARR_ENV=modular - POSTGRES_HOST=db - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret - REDIS_HOST=redis - CELERY_BROKER_URL=redis://redis:6379/0 - command: > - bash -c " - cd /app && - nice -n 5 celery -A dispatcharr worker -l info - " + - DISPATCHARR_LOG_LEVEL=info + #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19) + - DJANGO_SETTINGS_MODULE=dispatcharr.settings + - PYTHONUNBUFFERED=1 + # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE + entrypoint: ["/app/docker/entrypoint.celery.sh"] db: image: postgres:14 diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh new file mode 100644 index 00000000..fafe2c3f --- /dev/null +++ b/docker/entrypoint.celery.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +cd /app +source /dispatcharrpy/bin/activate + +# Wait for Django secret key +echo 'Waiting for Django secret key...' +while [ ! -f /data/jwt ]; do sleep 1; done +export DJANGO_SECRET_KEY=$(cat /data/jwt) + +# Wait for migrations to complete +echo 'Waiting for migrations to complete...' +until python manage.py showmigrations 2>&1 | grep -q '\[X\]'; do + echo 'Migrations not ready yet, waiting...' + sleep 2 +done + +# Start Celery +echo 'Migrations complete, starting Celery...' +celery -A dispatcharr beat -l info & +nice -n ${CELERY_NICE_LEVEL:-5} celery -A dispatcharr worker -l info --autoscale=6,1 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a50f2f49..36948c1f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -150,26 +150,43 @@ fi # Run init scripts echo "Starting user setup..." . /app/docker/init/01-user-setup.sh -echo "Setting up PostgreSQL..." -. /app/docker/init/02-postgres.sh + +# Initialize PostgreSQL if NOT in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + echo "Setting up PostgreSQL..." + . /app/docker/init/02-postgres.sh +fi + echo "Starting init process..." . /app/docker/init/03-init-dispatcharr.sh -# Start PostgreSQL -echo "Starting Postgres..." -su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" -# Wait for PostgreSQL to be ready -until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do - echo_with_timestamp "Waiting for PostgreSQL to be ready..." - sleep 1 -done -postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') -echo "✅ Postgres started with PID $postgres_pid" -pids+=("$postgres_pid") +# Start PostgreSQL if NOT in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + echo "Starting Postgres..." + su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + # Wait for PostgreSQL to be ready + until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL to be ready..." + sleep 1 + done + postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') + echo "✅ Postgres started with PID $postgres_pid" + pids+=("$postgres_pid") +else + echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}" + # Wait for external PostgreSQL to be ready + echo_with_timestamp "Waiting for external PostgreSQL to be ready..." + until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." + sleep 1 + done + echo "✅ External PostgreSQL is ready" +fi -# Ensure database encoding is UTF8 -. /app/docker/init/02-postgres.sh -ensure_utf8_encoding +# Ensure database encoding is UTF8 (only for internal database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + ensure_utf8_encoding +fi if [[ "$DISPATCHARR_ENV" = "dev" ]]; then . /app/docker/init/99-init-dev.sh @@ -197,6 +214,9 @@ if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then elif [ "$DISPATCHARR_DEBUG" = "true" ]; then echo "🚀 Starting uwsgi in debug mode..." uwsgi_file="/app/docker/uwsgi.debug.ini" +elif [ "$DISPATCHARR_ENV" = "modular" ]; then + echo "🚀 Starting uwsgi in modular mode..." + uwsgi_file="/app/docker/uwsgi.modular.ini" else echo "🚀 Starting uwsgi in production mode..." uwsgi_file="/app/docker/uwsgi.ini" diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini new file mode 100644 index 00000000..3220a8d8 --- /dev/null +++ b/docker/uwsgi.modular.ini @@ -0,0 +1,59 @@ +[uwsgi] +; Modular deployment mode - external PostgreSQL, Redis, and Celery +; Remove file creation commands since we're not logging to files anymore +; exec-pre = mkdir -p /data/logs +; exec-pre = touch /data/logs/uwsgi.log +; exec-pre = chmod 666 /data/logs/uwsgi.log + +; First run Redis availability check script once +exec-pre = python /app/scripts/wait_for_redis.py + +; Start Daphne for WebSocket support (required for real-time features) +; Redis and Celery run in separate containers in modular mode +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +env = USE_NGINX_ACCEL=true +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker management +workers = 4 + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +socket-timeout = 600 # Prevent write timeouts when client buffers +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 400 # Each unused greenlet costs ~2-4KB of memory +# Higher values have minimal performance impact when idle, but provide capacity for traffic spikes +# If memory usage becomes an issue, reduce this value + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false + +# Logging configuration +# Enable console logging (stdout) +log-master = true +# Enable strftime formatting for timestamps +logformat-strftime = true +log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 +# Use formatted time with environment variable for log level +log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms +log-buffering = 1024 # Add buffer size limit for logging From 236b2307e1e7878ddb2a2bb67ea4ede7312479cd Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Wed, 21 Jan 2026 17:40:36 -0600 Subject: [PATCH 02/63] Add health check and fix dangerous checks - docker-compose: added service health checks - dockerfile: added if logic for sed command so missing files do not cause build fail - entrypoint.celery: change migration detection to not continue until ALL migrations are applied - entrypoint: trim django key and change external postgres check to python in case postgres binaries are missing --- docker/Dockerfile | 10 ++++++---- docker/docker-compose.yml | 25 ++++++++++++++++++++----- docker/entrypoint.celery.sh | 6 +++--- docker/entrypoint.sh | 17 ++++++++++++++--- 4 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 409f1096..3e30a825 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,10 +30,12 @@ WORKDIR /app COPY . /app # Copy nginx configuration COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default -# Verify entrypoint scripts exist, fix line endings, and make them executable -RUN ls -la /app/docker/entrypoint*.sh && \ - sed -i 's/\r$//' /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh && \ - chmod +x /app/docker/entrypoint.sh /app/docker/entrypoint.celery.sh /app/docker/entrypoint.aio.sh +# Fix line endings and make entrypoint scripts executable +RUN for f in /app/docker/entrypoint*.sh; do \ + if [ -f "$f" ]; then \ + sed -i 's/\r$//' "$f" && chmod +x "$f"; \ + fi; \ + done # Clean out existing frontend folder RUN rm -rf /app/frontend # Copy built frontend assets diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index df295f90..e9254d30 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,8 +7,10 @@ services: volumes: - ./data:/data depends_on: - - db - - redis + db: + condition: service_healthy + redis: + condition: service_healthy environment: - DISPATCHARR_ENV=modular - POSTGRES_HOST=db @@ -50,9 +52,12 @@ services: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery depends_on: - - db - - redis - - web + db: + condition: service_healthy + redis: + condition: service_healthy + web: + condition: service_started volumes: - ./data:/data extra_hosts: @@ -85,10 +90,20 @@ services: - POSTGRES_PASSWORD=secret volumes: - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatcharr"] + interval: 5s + timeout: 5s + retries: 5 redis: image: redis:latest container_name: dispatcharr_redis + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 volumes: postgres_data: diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index fafe2c3f..0516cc54 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -7,11 +7,11 @@ source /dispatcharrpy/bin/activate # Wait for Django secret key echo 'Waiting for Django secret key...' while [ ! -f /data/jwt ]; do sleep 1; done -export DJANGO_SECRET_KEY=$(cat /data/jwt) +export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)" -# Wait for migrations to complete +# Wait for migrations to complete (check that NO unapplied migrations remain) echo 'Waiting for migrations to complete...' -until python manage.py showmigrations 2>&1 | grep -q '\[X\]'; do +until ! python manage.py showmigrations 2>&1 | grep -q '\[ \]'; do echo 'Migrations not ready yet, waiting...' sleep 2 done diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 36948c1f..0c17b9d0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -66,7 +66,7 @@ PY mv -f "$tmpfile" "$SECRET_FILE" || { echo "move failed"; rm -f "$tmpfile"; exit 1; } umask $old_umask fi -export DJANGO_SECRET_KEY="$(cat "$SECRET_FILE")" +export DJANGO_SECRET_KEY="$(tr -d '\r\n' < "$SECRET_FILE")" # Process priority configuration # UWSGI_NICE_LEVEL: Absolute nice value for uWSGI/streaming (default: 0 = normal priority) @@ -174,9 +174,20 @@ if [[ "$DISPATCHARR_ENV" != "modular" ]]; then pids+=("$postgres_pid") else echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}" - # Wait for external PostgreSQL to be ready + # Wait for external PostgreSQL to be ready using Python (no pg_isready needed) echo_with_timestamp "Waiting for external PostgreSQL to be ready..." - until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + until python3 -c " +import socket +import sys +try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + s.connect(('${POSTGRES_HOST}', ${POSTGRES_PORT})) + s.close() + sys.exit(0) +except Exception: + sys.exit(1) +" 2>/dev/null; do echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." sleep 1 done From 7a46577d2ec89ac65f20ae7249cc4194659fde7e Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Wed, 21 Jan 2026 17:46:07 -0600 Subject: [PATCH 03/63] Remove health check conditions Not supported on compose v3+ --- docker/docker-compose.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e9254d30..6673f896 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,10 +7,8 @@ services: volumes: - ./data:/data depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy + - db + - redis environment: - DISPATCHARR_ENV=modular - POSTGRES_HOST=db @@ -52,12 +50,9 @@ services: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - web: - condition: service_started + - db + - redis + - web volumes: - ./data:/data extra_hosts: From 8c2ffbe76a82972de35493826b3d29591e2240cf Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Wed, 21 Jan 2026 17:59:23 -0600 Subject: [PATCH 04/63] CELERY_NICE_LEVEL warning Changed the celery entrypoint to include a warning if CELERY_NICE_LEVEL is negative AND SYS_NICE is not configured (SYS_NICE is required for negative levels) --- docker/entrypoint.celery.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index 0516cc54..81cece86 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -19,4 +19,11 @@ done # Start Celery echo 'Migrations complete, starting Celery...' celery -A dispatcharr beat -l info & -nice -n ${CELERY_NICE_LEVEL:-5} celery -A dispatcharr worker -l info --autoscale=6,1 + +# Default to nice level 5 (lower priority) - safe for unprivileged containers +# Negative values require SYS_NICE capability +NICE_LEVEL="${CELERY_NICE_LEVEL:-5}" +if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then + echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability" +fi +nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1 From c698916a6091c6476516ebb11d24233486bbe63d Mon Sep 17 00:00:00 2001 From: Tobias Effner Date: Fri, 23 Jan 2026 22:45:37 +0100 Subject: [PATCH 05/63] feat: migrate to UV --- .github/workflows/base-image.yml | 4 +- .python-version | 1 + debian_install.sh | 25 +- docker/DispatcharrBase | 23 +- docker/init/99-init-dev.sh | 6 +- pyproject.toml | 53 + requirements.txt | 32 - uv.lock | 2098 ++++++++++++++++++++++++++++++ 8 files changed, 2190 insertions(+), 52 deletions(-) create mode 100644 .python-version create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml index d290d49a..e3a8a4ee 100644 --- a/.github/workflows/base-image.yml +++ b/.github/workflows/base-image.yml @@ -6,13 +6,13 @@ on: paths: - 'docker/DispatcharrBase' - '.github/workflows/base-image.yml' - - 'requirements.txt' + - 'pyproject.toml' pull_request: branches: [main, dev] paths: - 'docker/DispatcharrBase' - '.github/workflows/base-image.yml' - - 'requirements.txt' + - 'pyproject.toml' workflow_dispatch: # Allow manual triggering permissions: diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..24ee5b1b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/debian_install.sh b/debian_install.sh index bda506b1..3452e5c1 100755 --- a/debian_install.sh +++ b/debian_install.sh @@ -160,15 +160,28 @@ EOSU # 6) Setup Python Environment ############################################################################## +install_uv() { + echo ">>> Installing UV package manager..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +} + setup_python_env() { - echo ">>> Setting up Python virtual environment..." + echo ">>> Setting up Python virtual environment with UV..." + # Install UV globally first + install_uv + su - "$DISPATCH_USER" < /dev/null; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="\$HOME/.local/bin:\$PATH" +fi +# Create venv and install dependencies using UV +uv venv env --python $PYTHON_BIN +uv sync --python env/bin/python --no-install-project --no-dev EOSU ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg" } diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 149bfffb..4793102d 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -3,6 +3,8 @@ FROM lscr.io/linuxserver/ffmpeg:latest ENV DEBIAN_FRONTEND=noninteractive ENV VIRTUAL_ENV=/dispatcharrpy ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy # --- Install Python 3.13 and build dependencies --- # Note: Hardware acceleration (VA-API, VDPAU, NVENC) already included in base ffmpeg image @@ -18,24 +20,27 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ vlc-bin vlc-plugin-base \ build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build -# --- Create Python virtual environment --- -RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pip +# --- Install UV --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -# --- Install Python dependencies --- -COPY requirements.txt /tmp/requirements.txt -RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && \ - rm /tmp/requirements.txt +# --- Create Python virtual environment and install dependencies --- +WORKDIR /tmp/build +COPY pyproject.toml /tmp/build/ +RUN uv venv $VIRTUAL_ENV --python 3.13 && \ + uv sync --python $VIRTUAL_ENV/bin/python --no-cache --no-install-project --no-dev && \ + rm -rf /tmp/build +WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- -RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir build && \ +RUN uv pip install --python $VIRTUAL_ENV/bin/python --no-cache build && \ cd /tmp && \ - $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \ + uv pip download --python $VIRTUAL_ENV/bin/python --no-binary numpy --no-deps numpy && \ tar -xzf numpy-*.tar.gz && \ cd numpy-*/ && \ $VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ - $VIRTUAL_ENV/bin/pip uninstall -y build + uv pip uninstall --python $VIRTUAL_ENV/bin/python build # --- Clean up build dependencies to reduce image size --- RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh index 861c8307..c89f1e44 100644 --- a/docker/init/99-init-dev.sh +++ b/docker/init/99-init-dev.sh @@ -15,11 +15,11 @@ fi # Install frontend dependencies cd /app/frontend && npm install -# Install pip dependencies -cd /app && pip install -r requirements.txt +# Install Python dependencies using UV +cd /app && uv sync --python $VIRTUAL_ENV/bin/python --no-install-project --no-dev # Install debugpy for remote debugging if [ "$DISPATCHARR_DEBUG" = "true" ]; then echo "=== setting up debugpy ===" - pip install debugpy + uv pip install --python $VIRTUAL_ENV/bin/python debugpy fi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0a8b5cff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "dispatcharr" +version = "0.17.0" +description = "Dispatcharr - Stream dispatching and management" +readme = "README.md" +license = "MIT" +requires-python = ">=3.13" +dependencies = [ + "Django==5.2.9", + "psycopg2-binary==2.9.11", + "celery[redis]==5.6.0", + "djangorestframework==3.16.1", + "requests==2.32.5", + "psutil==7.1.3", + "pillow", + "drf-yasg>=1.21.11", + "streamlink", + "python-vlc", + "yt-dlp", + "gevent==25.9.1", + "daphne", + "uwsgi", + "django-cors-headers", + "djangorestframework-simplejwt", + "m3u8", + "rapidfuzz==3.14.3", + "regex", + "tzlocal", + "torch==2.9.1+cpu", + "sentence-transformers==5.2.0", + "channels", + "channels-redis==4.3.0", + "django-filter", + "django-celery-beat", + "lxml==6.0.2", + "gunicorn", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# PyTorch CPU-only wheels index +[[tool.uv.index]] +url = "https://download.pytorch.org/whl/cpu" +name = "pytorch-cpu" +explicit = true + +[tool.uv.sources] +torch = { index = "pytorch-cpu" } + +[tool.hatch.build.targets.wheel] +packages = ["dispatcharr", "apps", "core"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3416804d..00000000 --- a/requirements.txt +++ /dev/null @@ -1,32 +0,0 @@ -Django==5.2.9 -psycopg2-binary==2.9.11 -celery[redis]==5.6.0 -djangorestframework==3.16.1 -requests==2.32.5 -psutil==7.1.3 -pillow -drf-yasg>=1.21.11 -streamlink -python-vlc -yt-dlp -gevent==25.9.1 -daphne -uwsgi -django-cors-headers -djangorestframework-simplejwt -m3u8 -rapidfuzz==3.14.3 -regex # Required by transformers but also used for advanced regex features -tzlocal - -# PyTorch dependencies (CPU only) ---extra-index-url https://download.pytorch.org/whl/cpu/ -torch==2.9.1+cpu - -# ML/NLP dependencies -sentence-transformers==5.2.0 -channels -channels-redis==4.3.0 -django-filter -django-celery-beat -lxml==6.0.2 diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..8361f8fc --- /dev/null +++ b/uv.lock @@ -0,0 +1,2098 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "autobahn" +version = "25.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cbor2" }, + { name = "cffi" }, + { name = "cryptography" }, + { name = "hyperlink" }, + { name = "msgpack", marker = "platform_python_implementation == 'CPython'" }, + { name = "py-ubjson" }, + { name = "txaio" }, + { name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" }, + { name = "ujson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" }, + { url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f7/430074a5ea3f6187335a4ddc26f16dd75d5125e346a84cf132ddbd41a3e8/autobahn-25.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9e2a962f2de0bc4c53b452916458417a15f5137c956245ac6d0a783a83fa1f7", size = 2151873, upload-time = "2025-12-15T11:13:08.89Z" }, + { url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/e498821606db57305c8f3c26d9b28fd73e4e0583a1f48330df500721c418/autobahn-25.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:18b12e8af7fc115487715afa10b3f5b5a4b5989bebbe05b71722cf9fce7b1bfb", size = 2184111, upload-time = "2025-12-15T11:13:12.461Z" }, +] + +[[package]] +name = "automat" +version = "25.4.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "cbor2" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" }, + { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" }, + { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" }, + { url = "https://files.pythonhosted.org/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" }, +] + +[[package]] +name = "celery" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "exceptiongroup" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/5f/b681ae3c89290d2ea6562ea96b40f5af6f6fc5f7743e2cd1a19e47721548/celery-5.6.0.tar.gz", hash = "sha256:641405206042d52ae460e4e9751a2e31b06cf80ab836fcf92e0b9311d7ea8113", size = 1712522, upload-time = "2025-11-30T17:39:46.282Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "kombu", extra = ["redis"] }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "channels" +version = "4.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" }, +] + +[[package]] +name = "channels-redis" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "channels" }, + { name = "msgpack" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "constantly" +version = "23.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, +] + +[[package]] +name = "cron-descriptor" +version = "2.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "daphne" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "autobahn" }, + { name = "twisted", extra = ["tls"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" }, +] + +[[package]] +name = "dispatcharr" +version = "0.17.0" +source = { editable = "." } +dependencies = [ + { name = "celery", extra = ["redis"] }, + { name = "channels" }, + { name = "channels-redis" }, + { name = "daphne" }, + { name = "django" }, + { name = "django-celery-beat" }, + { name = "django-cors-headers" }, + { name = "django-filter" }, + { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, + { name = "drf-yasg" }, + { name = "gevent" }, + { name = "gunicorn" }, + { name = "lxml" }, + { name = "m3u8" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "psycopg2-binary" }, + { name = "python-vlc" }, + { name = "rapidfuzz" }, + { name = "regex" }, + { name = "requests" }, + { name = "sentence-transformers" }, + { name = "streamlink" }, + { name = "torch" }, + { name = "tzlocal" }, + { name = "uwsgi" }, + { name = "yt-dlp" }, +] + +[package.metadata] +requires-dist = [ + { name = "celery", extras = ["redis"], specifier = "==5.6.0" }, + { name = "channels" }, + { name = "channels-redis", specifier = "==4.3.0" }, + { name = "daphne" }, + { name = "django", specifier = "==5.2.9" }, + { name = "django-celery-beat" }, + { name = "django-cors-headers" }, + { name = "django-filter" }, + { name = "djangorestframework", specifier = "==3.16.1" }, + { name = "djangorestframework-simplejwt" }, + { name = "drf-yasg", specifier = ">=1.21.11" }, + { name = "gevent", specifier = "==25.9.1" }, + { name = "gunicorn" }, + { name = "lxml", specifier = "==6.0.2" }, + { name = "m3u8" }, + { name = "pillow" }, + { name = "psutil", specifier = "==7.1.3" }, + { name = "psycopg2-binary", specifier = "==2.9.11" }, + { name = "python-vlc" }, + { name = "rapidfuzz", specifier = "==3.14.3" }, + { name = "regex" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "sentence-transformers", specifier = "==5.2.0" }, + { name = "streamlink" }, + { name = "torch", specifier = "==2.9.1+cpu", index = "https://download.pytorch.org/whl/cpu" }, + { name = "tzlocal" }, + { name = "uwsgi" }, + { name = "yt-dlp" }, +] + +[[package]] +name = "django" +version = "5.2.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, +] + +[[package]] +name = "django-celery-beat" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "cron-descriptor" }, + { name = "django" }, + { name = "django-timezone-field" }, + { name = "python-crontab" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" }, +] + +[[package]] +name = "django-cors-headers" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, +] + +[[package]] +name = "django-filter" +version = "25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, +] + +[[package]] +name = "django-timezone-field" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, +] + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + +[[package]] +name = "drf-yasg" +version = "1.21.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "packaging" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/1b/2305804c1efbc2da1890fb2a34155ef298565eb152e1c00bd35546be42fa/drf_yasg-1.21.14.tar.gz", hash = "sha256:68d62d5f7505611ba9577115c83b36211cc20d9748a50ce95a44abe411727d10", size = 5153307, upload-time = "2026-01-15T11:37:07.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/de/61880040534036044572225c485b0a4adc99b4c92e3eed3e5741b31674fd/drf_yasg-1.21.14-py3-none-any.whl", hash = "sha256:725ddda28aec7efc4cab985290fe790c8565e665017f7c80211d288a35821c70", size = 4856031, upload-time = "2026-01-15T11:37:04.753Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, +] + +[[package]] +name = "gevent" +version = "25.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, + { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, + { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" }, + { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" }, + { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, + { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, + { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, + { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + +[[package]] +name = "gunicorn" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/8b/3cd14f3d78f050f8c703cb1a881d62f5fc695cfd9f258386982bdc1a018a/gunicorn-24.1.0.tar.gz", hash = "sha256:6df323c06bcb9499cbf8bc3b89a56619ac54d6a8c66130681e206272cc5b8e73", size = 286767, upload-time = "2026-01-23T20:50:38.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ea/f91518dd05d81eb8fb4c10c86802aa943d65e4df4dbc820ec58e2597b501/gunicorn-24.1.0-py3-none-any.whl", hash = "sha256:d7c3e50dfdade301b1a94ed3e9a2c07ab7d154908203813c10d7b175d04bbf23", size = 114495, upload-time = "2026-01-23T20:50:36.855Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, + { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, + { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, + { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, +] + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "incremental" +version = "24.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, +] + +[[package]] +name = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + +[[package]] +name = "m3u8" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, + { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, + { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, + { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, + { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, + { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, + { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, + { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, + { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, + { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, + { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, + { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, + { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, + { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, + { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, + { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, + { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, + { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, + { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, + { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, + { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "py-ubjson" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" } + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycountry" +version = "24.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "python-crontab" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-vlc" +version = "3.0.21203" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" }, + { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" }, + { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" }, + { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" }, + { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" }, + { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" }, + { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" }, + { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" }, + { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" }, + { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" }, + { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" }, + { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" }, + { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, + { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, + { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, + { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, + { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, + { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, + { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, + { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, + { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, + { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, + { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, + { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, + { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, +] + +[[package]] +name = "sentence-transformers" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "torch" }, + { name = "tqdm" }, + { name = "transformers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/a1/64e7b111e753307ffb7c5b6d039c52d4a91a47fa32a7f5bc377a49b22402/sentence_transformers-5.2.0.tar.gz", hash = "sha256:acaeb38717de689f3dab45d5e5a02ebe2f75960a4764ea35fea65f58a4d3019f", size = 381004, upload-time = "2025-12-11T14:12:31.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/d0/3b2897ef6a0c0c801e9fecca26bcc77081648e38e8c772885ebdd8d7d252/sentence_transformers-5.2.0-py3-none-any.whl", hash = "sha256:aa57180f053687d29b08206766ae7db549be5074f61849def7b17bf0b8025ca2", size = 493748, upload-time = "2025-12-11T14:12:29.516Z" }, +] + +[[package]] +name = "service-identity" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cryptography" }, + { name = "pyasn1" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "streamlink" +version = "8.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "isodate" }, + { name = "lxml" }, + { name = "pycountry" }, + { name = "pycryptodome" }, + { name = "pysocks" }, + { name = "requests" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/89/ea3baa9d0c2d564412be15ef5e799a56bf2a0068b27197e5e4c312ba13f5/streamlink-8.1.2.tar.gz", hash = "sha256:d08099fa1b169bad4a991d31b2ab89f04ba08b1319ed1f5bd0ead8547d4c5ad3", size = 823590, upload-time = "2026-01-18T12:21:53.28Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/8f/124e4db6bbf8fe3ab9bfe4dbd029f29211629c0f1733da2695576917c226/streamlink-8.1.2-py3-none-any.whl", hash = "sha256:2355cffcdbe60e8270144b5191a20b2ab195bbb9150b453334a23b829d1d75c7", size = 556372, upload-time = "2026-01-18T12:21:47.062Z" }, + { url = "https://files.pythonhosted.org/packages/c9/11/63a8c6b2b733d55189888504530865f68a3b69ebde7b78138d680e56bb45/streamlink-8.1.2-py3-none-win32.whl", hash = "sha256:c5aba06f24bb7a6a261ec409a301b5d8564ea865933f08001533067e5e40529c", size = 556386, upload-time = "2026-01-18T12:21:49.505Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/81a8bbe0d8755154979a4cd56f2b03da67ddd2db7ab9e14145a35cf0eb72/streamlink-8.1.2-py3-none-win_amd64.whl", hash = "sha256:86043d339a1b9b9b49f36ed26cdd421d226a021f06c9434e2177bfc6caf420d5", size = 556389, upload-time = "2026-01-18T12:21:51.385Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "torch" +version = "2.9.1+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:404a7ab2fffaf2ca069e662f331eb46313692b2f1630df2720094284f390ccef" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:161decbff26a33f13cb5ba6d2c8f458bbf56193bcc32ecc70be6dd4c7a3ee79d" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:e586ab1363e3f86aa4cc133b7fdcf98deb1d2c13d43a7a6e5a6a18e9c5364893" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:f60e2565f261542efac07e25208fb3fc55c6fe82314a5a9cbee971edb5f27713" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:cf9c3e50b595721ca6b488bdcc326e0f1af73ed28b9b66eff504a96649bb5c96" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "transformers" +version = "4.57.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, +] + +[[package]] +name = "trio" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "twisted" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "automat" }, + { name = "constantly" }, + { name = "hyperlink" }, + { name = "incremental" }, + { name = "typing-extensions" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, +] + +[package.optional-dependencies] +tls = [ + { name = "idna" }, + { name = "pyopenssl" }, + { name = "service-identity" }, +] + +[[package]] +name = "txaio" +version = "25.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/67/ea9c9ddbaa3e0b4d53c91f8778a33e42045be352dc7200ed2b9aaa7dc229/txaio-25.12.2.tar.gz", hash = "sha256:9f232c21e12aa1ff52690e365b5a0ecfd42cc27a6ec86e1b92ece88f763f4b78", size = 117393, upload-time = "2025-12-09T15:03:26.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/05/bdb6318120cac9bf97779674f49035e0595d894b42d4c43b60637bafdb1f/txaio-25.12.2-py3-none-any.whl", hash = "sha256:5f6cd6c6b397fc3305790d15efd46a2d5b91cdbefa96543b4f8666aeb56ba026", size = 31208, upload-time = "2025-12-09T04:30:27.811Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "u-msgpack-python" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9d/a40411a475e7d4838994b7f6bcc6bfca9acc5b119ce3a7503608c4428b49/u-msgpack-python-2.8.0.tar.gz", hash = "sha256:b801a83d6ed75e6df41e44518b4f2a9c221dc2da4bcd5380e3a0feda520bc61a", size = 18167, upload-time = "2023-05-18T09:28:12.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" }, +] + +[[package]] +name = "ujson" +version = "5.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" }, + { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" }, + { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" }, + { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" }, + { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" }, + { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" }, + { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" }, + { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" }, + { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uwsgi" +version = "2.0.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/49/2f57640e889ba509fd1fae10cccec1b58972a07c2724486efba94c5ea448/uwsgi-2.0.31.tar.gz", hash = "sha256:e8f8b350ccc106ff93a65247b9136f529c14bf96b936ac5b264c6ff9d0c76257", size = 822796, upload-time = "2025-10-11T19:17:28.794Z" } + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/07/0b5bcc9812b1b2fd331cc88289ef4d47d428afdbbf0216bb7d53942d93d6/wcwidth-0.3.2.tar.gz", hash = "sha256:d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad", size = 233633, upload-time = "2026-01-23T21:08:52.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315", size = 86280, upload-time = "2026-01-23T21:08:51.362Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yt-dlp" +version = "2025.12.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947, upload-time = "2025-12-08T00:16:01.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464, upload-time = "2025-12-08T00:15:58.556Z" }, +] + +[[package]] +name = "zope-event" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" }, + { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" }, + { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" }, + { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" }, + { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" }, +] From 557e192d4c2df053fee966be0b8f70efe951c432 Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Fri, 23 Jan 2026 19:15:27 -0600 Subject: [PATCH 06/63] Add UTF8 encoding check for external databases Extend ensure_utf8_encoding() to support both internal and external PostgreSQL. - Modular mode: Uses TCP connection with PGPASSWORD authentication - AIO mode: Uses Unix socket as postgres user - Explicitly set database owner during recreation (fixes missing --owner flag) - Conversion logic (dump/drop/recreate/restore) works for both AIO and modular modes - Skip internal PostgreSQL setup entirely when DISPATCHARR_ENV=modular --- docker/entrypoint.sh | 14 ++++------ docker/init/02-postgres.sh | 54 +++++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0c17b9d0..f1515ee0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -151,11 +151,9 @@ fi echo "Starting user setup..." . /app/docker/init/01-user-setup.sh -# Initialize PostgreSQL if NOT in modular mode (using external database) -if [[ "$DISPATCHARR_ENV" != "modular" ]]; then - echo "Setting up PostgreSQL..." - . /app/docker/init/02-postgres.sh -fi +# Initialize PostgreSQL (script handles modular vs internal mode internally) +echo "Setting up PostgreSQL..." +. /app/docker/init/02-postgres.sh echo "Starting init process..." . /app/docker/init/03-init-dispatcharr.sh @@ -194,10 +192,8 @@ except Exception: echo "✅ External PostgreSQL is ready" fi -# Ensure database encoding is UTF8 (only for internal database) -if [[ "$DISPATCHARR_ENV" != "modular" ]]; then - ensure_utf8_encoding -fi +# Ensure database encoding is UTF8 (handles both internal and external databases) +ensure_utf8_encoding if [[ "$DISPATCHARR_ENV" = "dev" ]]; then . /app/docker/init/99-init-dev.sh diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index e36dd744..a9237df0 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -1,4 +1,8 @@ #!/bin/bash + +# Skip internal PostgreSQL setup in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + # Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove # some time in the future. if [ -e "/data/postgresql.conf" ]; then @@ -139,27 +143,51 @@ EOF done fi +fi # End of DISPATCHARR_ENV != modular check + ensure_utf8_encoding() { # Check encoding of existing database - CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | tr -d ' ') + # Supports both internal (Unix socket) and external (TCP) PostgreSQL + echo "Checking database encoding..." + + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + # External database: use TCP connection with password + CURRENT_ENCODING=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();" 2>/dev/null | tr -d ' ') + else + # Internal database: use Unix socket as postgres user + CURRENT_ENCODING=$(su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ') + fi + if [ "$CURRENT_ENCODING" != "UTF8" ]; then echo "Database $POSTGRES_DB encoding is $CURRENT_ENCODING, converting to UTF8..." DUMP_FILE="/tmp/${POSTGRES_DB}_utf8_dump_$(date +%s).sql" - # Dump database (include permissions and ownership) - su - postgres -c "pg_dump -p ${POSTGRES_PORT} $POSTGRES_DB > $DUMP_FILE" - # Drop and recreate database with UTF8 encoding using template0 - su - postgres -c "dropdb -p ${POSTGRES_PORT} $POSTGRES_DB" - # Recreate database with UTF8 encoding - su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 ${POSTGRES_DB}" - - - # Restore data - su - postgres -c "psql -p ${POSTGRES_PORT} -d $POSTGRES_DB < $DUMP_FILE" - #configure_db + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + # External database: use TCP connection with password + # Dump database (include permissions and ownership) + PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + # Drop and recreate database with UTF8 encoding using template0 + PGPASSWORD="$POSTGRES_PASSWORD" dropdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" || { echo "Drop failed"; return 1; } + # Recreate database with UTF8 encoding + PGPASSWORD="$POSTGRES_PASSWORD" createdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" --encoding=UTF8 --template=template0 "$POSTGRES_DB" || { echo "Create failed"; return 1; } + # Restore data + PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE" || { echo "Restore failed"; return 1; } + else + # Internal database: use Unix socket as postgres user + # Dump database (include permissions and ownership) + su - postgres -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + # Drop and recreate database with UTF8 encoding using template0 + su - postgres -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; } + # Recreate database with UTF8 encoding and correct owner + su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; } + # Restore data + cat "$DUMP_FILE" | su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; } + fi rm -f "$DUMP_FILE" - echo "Database $POSTGRES_DB converted to UTF8 and permissions set." + echo "✅ Database $POSTGRES_DB converted to UTF8." + else + echo "✅ Database encoding is UTF8" fi } From 47b591bacb7e38d3d617ca1ecbc8c08b5050672a Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Fri, 23 Jan 2026 21:28:53 -0600 Subject: [PATCH 07/63] Add configurable POSTGRES_PORT and REDIS_PORT for external databases Make database ports configurable for users with external PostgreSQL/Redis. - Add POSTGRES_PORT and REDIS_PORT to docker-compose.yml (web and celery) - Remove hardcoded CELERY_BROKER_URL (Django builds it from components) - Add REDIS_PORT export to entrypoint.sh and profile variables - Add Redis connection check for modular mode (matches PostgreSQL pattern) Users can now specify custom ports for external databases: [defaults] POSTGRES_PORT=5432 REDIS_PORT=6379 --- docker/docker-compose.yml | 6 ++++-- docker/entrypoint.sh | 25 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6673f896..9d921c2d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,11 +12,12 @@ services: environment: - DISPATCHARR_ENV=modular - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret - REDIS_HOST=redis - - CELERY_BROKER_URL=redis://redis:6379/0 + - REDIS_PORT=6379 - DISPATCHARR_LOG_LEVEL=info # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) @@ -60,11 +61,12 @@ services: environment: - DISPATCHARR_ENV=modular - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret - REDIS_HOST=redis - - CELERY_BROKER_URL=redis://redis:6379/0 + - REDIS_PORT=6379 - DISPATCHARR_LOG_LEVEL=info #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19) - DJANGO_SETTINGS_MODULE=dispatcharr.settings diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index f1515ee0..e6fa8ef0 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -48,6 +48,7 @@ export POSTGRES_PORT=${POSTGRES_PORT:-5432} export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1) export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" export REDIS_HOST=${REDIS_HOST:-localhost} +export REDIS_PORT=${REDIS_PORT:-6379} export REDIS_DB=${REDIS_DB:-0} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' @@ -115,7 +116,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_DB POSTGRES_DIR DISPATCHARR_PORT + REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) @@ -192,6 +193,28 @@ except Exception: echo "✅ External PostgreSQL is ready" fi +# Wait for Redis to be ready (modular mode uses external Redis) +if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}" + echo_with_timestamp "Waiting for external Redis to be ready..." + until python3 -c " +import socket +import sys +try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(2) + s.connect(('${REDIS_HOST}', ${REDIS_PORT})) + s.close() + sys.exit(0) +except Exception: + sys.exit(1) +" 2>/dev/null; do + echo_with_timestamp "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..." + sleep 1 + done + echo "✅ External Redis is ready" +fi + # Ensure database encoding is UTF8 (handles both internal and external databases) ensure_utf8_encoding From 8d69fd93743f2524ccffeba27712f2434c3b21d4 Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Sun, 25 Jan 2026 22:05:28 -0600 Subject: [PATCH 08/63] Add configurable EPG matching normalization settings (Feature #771) Implements user-configurable string removal for EPG matching to improve channel-to-EPG association accuracy. Settings are non-destructive and only affect EPG matching normalization, never modifying actual channel display names. --- apps/channels/tasks.py | 66 +++++++++++- core/models.py | 23 ++++ .../forms/settings/EPGSettingsForm.jsx | 102 ++++++++++++++++++ frontend/src/pages/Settings.jsx | 14 +++ .../forms/settings/EPGSettingsFormUtils.js | 11 ++ frontend/src/utils/pages/SettingsUtils.js | 29 ++++- 6 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/forms/settings/EPGSettingsForm.jsx create mode 100644 frontend/src/utils/forms/settings/EPGSettingsFormUtils.js diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index b9983b87..cd597ca3 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -139,6 +139,7 @@ COMMON_EXTRANEOUS_WORDS = [ def normalize_name(name: str) -> str: """ A more aggressive normalization that: + - Removes user-configured prefixes/suffixes/custom strings (if configured) - Lowercases - Removes bracketed/parenthesized text - Removes punctuation @@ -148,7 +149,70 @@ def normalize_name(name: str) -> str: if not name: return "" - norm = name.lower() + # Load user-configured EPG matching rules (fail gracefully) + prefixes = [] + suffixes = [] + custom_strings = [] + + try: + from core.models import CoreSettings + settings = CoreSettings.get_epg_settings() + prefixes = settings.get("epg_match_ignore_prefixes", []) + suffixes = settings.get("epg_match_ignore_suffixes", []) + custom_strings = settings.get("epg_match_ignore_custom", []) + + # Ensure we have lists (defensive) + if not isinstance(prefixes, list): + prefixes = [] + if not isinstance(suffixes, list): + suffixes = [] + if not isinstance(custom_strings, list): + custom_strings = [] + + except Exception as e: + # Settings unavailable or error - continue with empty lists (graceful degradation) + logger.debug(f"Could not load EPG matching settings: {e}") + prefixes = [] + suffixes = [] + custom_strings = [] + + result = name + + # Step 1: Remove prefixes (from START only - exact string match) + for prefix in prefixes: + # Skip empty or non-string entries + if not prefix or not isinstance(prefix, str): + continue + # Exact match at start + if result.startswith(prefix): + result = result[len(prefix):] + break # Only remove first matching prefix + + # Step 2: Remove suffixes (from END only - exact string match) + for suffix in suffixes: + # Skip empty or non-string entries + if not suffix or not isinstance(suffix, str): + continue + # Exact match at end + if result.endswith(suffix): + result = result[:-len(suffix)] + break # Only remove first matching suffix + + # Step 3: Remove custom strings (from ANYWHERE - exact string match) + for custom in custom_strings: + # Skip empty or non-string entries + if not custom or not isinstance(custom, str): + continue + try: + # Exact string removal (replace with empty string) + result = result.replace(custom, "") + except Exception as e: + # If removal fails for any reason, skip this entry + logger.debug(f"Failed to remove custom string '{custom}': {e}") + continue + + # Step 4: Existing normalization logic (unchanged) + norm = result.lower() norm = re.sub(r"\[.*?\]", "", norm) # Extract and preserve important call signs from parentheses before removing them diff --git a/core/models.py b/core/models.py index 683acb0d..1037a6e3 100644 --- a/core/models.py +++ b/core/models.py @@ -155,6 +155,7 @@ BACKUP_SETTINGS_KEY = "backup_settings" PROXY_SETTINGS_KEY = "proxy_settings" NETWORK_ACCESS_KEY = "network_access" SYSTEM_SETTINGS_KEY = "system_settings" +EPG_SETTINGS_KEY = "epg_settings" class CoreSettings(models.Model): @@ -227,6 +228,28 @@ class CoreSettings(models.Model): def get_auto_import_mapped_files(cls): return cls.get_stream_settings().get("auto_import_mapped_files") + # EPG Settings + @classmethod + def get_epg_settings(cls): + """Get all EPG-related settings.""" + return cls._get_group(EPG_SETTINGS_KEY, { + "epg_match_ignore_prefixes": [], + "epg_match_ignore_suffixes": [], + "epg_match_ignore_custom": [], + }) + + @classmethod + def get_epg_match_ignore_prefixes(cls): + return cls.get_epg_settings().get("epg_match_ignore_prefixes", []) + + @classmethod + def get_epg_match_ignore_suffixes(cls): + return cls.get_epg_settings().get("epg_match_ignore_suffixes", []) + + @classmethod + def get_epg_match_ignore_custom(cls): + return cls.get_epg_settings().get("epg_match_ignore_custom", []) + # DVR Settings @classmethod def get_dvr_settings(cls): diff --git a/frontend/src/components/forms/settings/EPGSettingsForm.jsx b/frontend/src/components/forms/settings/EPGSettingsForm.jsx new file mode 100644 index 00000000..7cfcc513 --- /dev/null +++ b/frontend/src/components/forms/settings/EPGSettingsForm.jsx @@ -0,0 +1,102 @@ +import useSettingsStore from '../../../store/settings.jsx'; +import React, { useEffect, useState } from 'react'; +import { + getChangedSettings, + parseSettings, + saveChangedSettings, +} from '../../../utils/pages/SettingsUtils.js'; +import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { getEPGSettingsFormInitialValues } from '../../../utils/forms/settings/EPGSettingsFormUtils.js'; + +const EPGSettingsForm = React.memo(({ active }) => { + const settings = useSettingsStore((s) => s.settings); + + const [saved, setSaved] = useState(false); + + const form = useForm({ + mode: 'controlled', + initialValues: getEPGSettingsFormInitialValues(), + }); + + useEffect(() => { + if (!active) setSaved(false); + }, [active]); + + useEffect(() => { + if (settings) { + const formValues = parseSettings(settings); + + form.setValues(formValues); + } + }, [settings]); + + const onSubmit = async () => { + setSaved(false); + + const changedSettings = getChangedSettings(form.getValues(), settings); + + // Update each changed setting in the backend (create if missing) + try { + await saveChangedSettings(settings, changedSettings); + + setSaved(true); + } catch (error) { + // Error notifications are already shown by API functions + // Just don't show the success message + console.error('Error saving settings:', error); + } + }; + + return ( + + {saved && ( + + )} + + Configure how channel names are normalized during EPG matching. + These settings help channels with different names match the same EPG data. + Channel display names are never modified. + + + + + + + + + + + + + ); +}); + +export default EPGSettingsForm; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 4ce519a3..bbe4631f 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -25,6 +25,8 @@ const ProxySettingsForm = React.lazy(() => import('../components/forms/settings/ProxySettingsForm.jsx')); const StreamSettingsForm = React.lazy(() => import('../components/forms/settings/StreamSettingsForm.jsx')); +const EPGSettingsForm = React.lazy(() => + import('../components/forms/settings/EPGSettingsForm.jsx')); const DvrSettingsForm = React.lazy(() => import('../components/forms/settings/DvrSettingsForm.jsx')); const SystemSettingsForm = React.lazy(() => @@ -78,6 +80,18 @@ const SettingsPage = () => { + + EPG Settings + + + }> + + + + + + System Settings diff --git a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js new file mode 100644 index 00000000..7babbbf9 --- /dev/null +++ b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js @@ -0,0 +1,11 @@ +export const getEPGSettingsFormInitialValues = () => { + return { + epg_match_ignore_prefixes: [], + epg_match_ignore_suffixes: [], + epg_match_ignore_custom: [], + }; +}; + +export const getEPGSettingsFormValidation = () => { + return {}; +}; diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index 6ee12f60..9cd4b2c8 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -20,6 +20,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { // Group changes by their setting group based on field name prefixes const groupedChanges = { stream_settings: {}, + epg_settings: {}, dvr_settings: {}, backup_settings: {}, system_settings: {}, @@ -27,6 +28,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { // Map of field prefixes to their groups const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files']; + const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom']; const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template', 'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules']; const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week', @@ -58,6 +60,7 @@ export const saveChangedSettings = async (settings, changedSettings) => { } // Type conversions for proper storage + // EPG fields should remain as arrays, don't convert them if (formKey === 'm3u_hash_key' && Array.isArray(value)) { value = value.join(','); } @@ -79,6 +82,8 @@ export const saveChangedSettings = async (settings, changedSettings) => { // Route to appropriate group if (streamFields.includes(formKey)) { groupedChanges.stream_settings[formKey] = value; + } else if (epgFields.includes(formKey)) { + groupedChanges.epg_settings[formKey] = value; } else if (dvrFields.includes(formKey)) { groupedChanges.dvr_settings[formKey] = value; } else if (backupFields.includes(formKey)) { @@ -114,6 +119,9 @@ export const saveChangedSettings = async (settings, changedSettings) => { export const getChangedSettings = (values, settings) => { const changedSettings = {}; + // EPG fields that should be kept as arrays + const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom']; + for (const settingKey in values) { // Skip grouped settings that are handled by their own dedicated forms if (settingKey === 'proxy_settings' || settingKey === 'network_access') { @@ -123,10 +131,20 @@ export const getChangedSettings = (values, settings) => { // Only compare against existing value if the setting exists const existing = settings[settingKey]; - // Convert array values (like m3u_hash_key) to comma-separated strings for comparison - let compareValue; let actualValue = values[settingKey]; + let compareValue; + // Handle EPG fields specially - keep as arrays, don't skip empty arrays + if (epgFields.includes(settingKey)) { + if (!Array.isArray(actualValue)) { + actualValue = []; + } + // Always include EPG fields in changes (even if empty) + changedSettings[settingKey] = actualValue; + continue; + } + + // Convert array values (like m3u_hash_key) to comma-separated strings for comparison if (Array.isArray(actualValue)) { actualValue = actualValue.join(','); compareValue = actualValue; @@ -173,6 +191,13 @@ export const parseSettings = (settings) => { } } + // EPG settings - direct mapping with underscore keys + const epgSettings = settings['epg_settings']?.value; + // Always set EPG fields (even if settings don't exist yet) + parsed.epg_match_ignore_prefixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)) ? epgSettings.epg_match_ignore_prefixes : []; + parsed.epg_match_ignore_suffixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)) ? epgSettings.epg_match_ignore_suffixes : []; + parsed.epg_match_ignore_custom = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)) ? epgSettings.epg_match_ignore_custom : []; + // DVR settings - direct mapping with underscore keys const dvrSettings = settings['dvr_settings']?.value; if (dvrSettings && typeof dvrSettings === 'object') { From a1fa737ccdbc260e06e51cf98499eae29d533c77 Mon Sep 17 00:00:00 2001 From: Matt Grutza Date: Sun, 25 Jan 2026 22:08:32 -0600 Subject: [PATCH 09/63] Update verbiage --- apps/channels/tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index cd597ca3..8a0dffe1 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -161,7 +161,7 @@ def normalize_name(name: str) -> str: suffixes = settings.get("epg_match_ignore_suffixes", []) custom_strings = settings.get("epg_match_ignore_custom", []) - # Ensure we have lists (defensive) + # Ensure we have lists if not isinstance(prefixes, list): prefixes = [] if not isinstance(suffixes, list): From 8616933ad7852082c6d779e65fd63ad289215e1b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 13:17:59 -0600 Subject: [PATCH 10/63] Replace drf-yasg pip with drf-spectactular --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3416804d..1f3c63aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ djangorestframework==3.16.1 requests==2.32.5 psutil==7.1.3 pillow -drf-yasg>=1.21.11 +drf-spectacular>=0.28.0 streamlink python-vlc yt-dlp From 53641237452b5cdcfed0ce8a9963356488d9f414 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 13:33:33 -0600 Subject: [PATCH 11/63] - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added - Modern OpenAPI 3.0 specification compliance - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection --- CHANGELOG.md | 8 + apps/accounts/api_views.py | 63 +++-- apps/api/urls.py | 26 +- apps/channels/api_views.py | 487 +++++++++++++------------------------ apps/epg/api_views.py | 39 ++- apps/hdhr/api_views.py | 24 +- apps/hdhr/views.py | 24 +- apps/m3u/api_views.py | 19 +- core/api_views.py | 27 +- dispatcharr/settings.py | 24 +- dispatcharr/urls.py | 28 +-- 11 files changed, 290 insertions(+), 479 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97952632..26c3abd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: + - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added + - Modern OpenAPI 3.0 specification compliance + - Better auto-generation of request/response schemas + - Improved documentation accuracy with serializer introspection + ## [0.18.1] - 2026-01-27 ### Fixed diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 41e2f077..607ce199 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -4,9 +4,9 @@ from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt from rest_framework.decorators import api_view, permission_classes, action from rest_framework.response import Response -from rest_framework import viewsets, status -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from rest_framework import viewsets, status, serializers +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes import json from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed @@ -147,19 +147,15 @@ class AuthViewSet(viewsets.ViewSet): return [IsAuthenticated()] return [] - @swagger_auto_schema( - operation_description="Authenticate and log in a user", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["username", "password"], - properties={ - "username": openapi.Schema(type=openapi.TYPE_STRING), - "password": openapi.Schema( - type=openapi.TYPE_STRING, format=openapi.FORMAT_PASSWORD - ), + @extend_schema( + description="Authenticate and log in a user", + request=inline_serializer( + name="LoginRequest", + fields={ + "username": serializers.CharField(), + "password": serializers.CharField(), }, ), - responses={200: "Login successful", 400: "Invalid credentials"}, ) def login(self, request): """Logs in a user and returns user details""" @@ -209,9 +205,8 @@ class AuthViewSet(viewsets.ViewSet): ) return Response({"error": "Invalid credentials"}, status=400) - @swagger_auto_schema( - operation_description="Log out the current user", - responses={200: "Logout successful"}, + @extend_schema( + description="Log out the current user", ) def logout(self, request): """Logs out the authenticated user""" @@ -245,32 +240,31 @@ class UserViewSet(viewsets.ModelViewSet): return [IsAdmin()] - @swagger_auto_schema( - operation_description="Retrieve a list of users", + @extend_schema( + description="Retrieve a list of users", responses={200: UserSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific user by ID") + @extend_schema(description="Retrieve a specific user by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new user") + @extend_schema(description="Create a new user") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a user") + @extend_schema(description="Update a user") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a user") + @extend_schema(description="Delete a user") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) - @swagger_auto_schema( - method="get", - operation_description="Get active user information", + @extend_schema( + description="Get active user information", ) @action(detail=False, methods=["get"], url_path="me") def me(self, request): @@ -287,34 +281,33 @@ class GroupViewSet(viewsets.ModelViewSet): serializer_class = GroupSerializer permission_classes = [Authenticated] - @swagger_auto_schema( - operation_description="Retrieve a list of groups", + @extend_schema( + description="Retrieve a list of groups", responses={200: GroupSerializer(many=True)}, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Retrieve a specific group by ID") + @extend_schema(description="Retrieve a specific group by ID") def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Create a new group") + @extend_schema(description="Create a new group") def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Update a group") + @extend_schema(description="Update a group") def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) - @swagger_auto_schema(operation_description="Delete a group") + @extend_schema(description="Delete a group") def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) # 🔹 4) Permissions List API -@swagger_auto_schema( - method="get", - operation_description="Retrieve a list of all permissions", +@extend_schema( + description="Retrieve a list of all permissions", responses={200: PermissionSerializer(many=True)}, ) @api_view(["GET"]) diff --git a/apps/api/urls.py b/apps/api/urls.py index 4c92c70a..5a688778 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,23 +1,8 @@ from django.urls import path, include, re_path -from drf_yasg.views import get_schema_view -from drf_yasg import openapi -from rest_framework.permissions import AllowAny +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView app_name = 'api' -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version='v1', - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="support@dispatcharr.local"), - license=openapi.License(name="Unlicense"), - ), - public=True, - permission_classes=(AllowAny,), -) - urlpatterns = [ path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')), path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')), @@ -35,8 +20,9 @@ urlpatterns = [ - # Swagger Documentation api_urls - re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), - path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), - path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'), + # OpenAPI Schema and Documentation (drf-spectacular) + path('schema/', SpectacularAPIView.as_view(), name='schema'), + re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), + path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), ] diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 11e7525f..27e07dac 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -4,8 +4,9 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework.decorators import action from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction from django.db.models import Count, F @@ -269,17 +270,15 @@ class StreamViewSet(viewsets.ModelViewSet): ] }) - @swagger_auto_schema( - method="post", - operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["ids"], - properties={ - "ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to retrieve" + @extend_schema( + methods=["POST"], + description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", + request=inline_serializer( + name="StreamByIdsRequest", + fields={ + "ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to retrieve" ), }, ), @@ -342,10 +341,9 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): return super().partial_update(request, *args, **kwargs) - @swagger_auto_schema( - method="post", - operation_description="Delete all channel groups that have no associations (no channels or M3U accounts)", - responses={200: "Cleanup completed"}, + @extend_schema( + methods=["POST"], + description="Delete all channel groups that have no associations (no channels or M3U accounts)", ) @action(detail=False, methods=["post"], url_path="cleanup") def cleanup_unused_groups(self, request): @@ -831,25 +829,22 @@ class ChannelViewSet(viewsets.ModelViewSet): # Return the response with the list of IDs return Response(list(channel_ids)) - @swagger_auto_schema( - method="post", - operation_description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "starting_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="Starting channel number to assign (can be decimal)", + @extend_schema( + methods=["POST"], + description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", + request=inline_serializer( + name="AssignChannelsRequest", + fields={ + "starting_number": serializers.FloatField( + help_text="Starting channel number to assign (can be decimal)", + required=False, ), - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to assign", + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to assign", ), }, ), - responses={200: "Channels have been auto-assigned!"}, ) @action(detail=False, methods=["post"], url_path="assign") def assign(self, request): @@ -869,33 +864,28 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK ) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Create a new channel from an existing stream. " "If 'channel_number' is provided, it will be used (if available); " "otherwise, the next available channel number is assigned. " "If 'channel_profile_ids' is provided, the channel will only be added to those profiles. " "Accepts either a single ID or an array of IDs." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_id"], - properties={ - "stream_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="ID of the stream to link" + request=inline_serializer( + name="FromStreamRequest", + fields={ + "stream_id": serializers.IntegerField(help_text="ID of the stream to link"), + "channel_number": serializers.FloatField( + help_text="(Optional) Desired channel number. Must not be in use.", + required=False, ), - "channel_number": openapi.Schema( - type=openapi.TYPE_NUMBER, - description="(Optional) Desired channel number. Must not be in use.", - ), - "name": openapi.Schema( - type=openapi.TYPE_STRING, description="Desired channel name" - ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." + "name": serializers.CharField(help_text="Desired channel name", required=False), + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), }, ), @@ -1055,34 +1045,31 @@ class ChannelViewSet(viewsets.ModelViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + methods=["POST"], + description=( "Asynchronously bulk create channels from stream IDs. " "Returns a task ID to track progress via WebSocket. " "This is the recommended approach for large bulk operations." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="List of stream IDs to create channels from" + request=inline_serializer( + name="FromStreamBulkRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to create channels from" ), - "channel_profile_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles." + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, ), - "starting_channel_number": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number" + "starting_channel_number": serializers.IntegerField( + help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number", + required=False, ), }, ), - responses={202: "Task started successfully"}, ) @action(detail=False, methods=["post"], url_path="from-stream/bulk") def from_stream_bulk(self, request): @@ -1122,20 +1109,19 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 6) EPG Fuzzy Matching # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.' + @extend_schema( + methods=["POST"], + description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", + request=inline_serializer( + name="MatchEpgRequest", + fields={ + 'channel_ids': serializers.ListField( + child=serializers.IntegerField(), + help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.', + required=False, ) } ), - responses={202: "EPG matching task initiated"}, ) @action(detail=False, methods=["post"], url_path="match-epg") def match_epg(self, request): @@ -1156,10 +1142,9 @@ class ChannelViewSet(viewsets.ModelViewSet): {"message": message}, status=status.HTTP_202_ACCEPTED ) - @swagger_auto_schema( - method="post", - operation_description="Try to auto-match this specific channel with EPG data.", - responses={200: "EPG matching completed", 202: "EPG matching task initiated"}, + @extend_schema( + methods=["POST"], + description="Try to auto-match this specific channel with EPG data.", ) @action(detail=True, methods=["post"], url_path="match-epg") def match_channel_epg(self, request, pk=None): @@ -1186,16 +1171,13 @@ class ChannelViewSet(viewsets.ModelViewSet): # ───────────────────────────────────────────────────────── # 7) Set EPG and Refresh # ───────────────────────────────────────────────────────── - @swagger_auto_schema( - method="post", - operation_description="Set EPG data for a channel and refresh program data", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["epg_data_id"], - properties={ - "epg_data_id": openapi.Schema( - type=openapi.TYPE_INTEGER, description="EPG data ID to link" - ) + @extend_schema( + methods=["POST"], + description="Set EPG data for a channel and refresh program data", + request=inline_serializer( + name="SetEpgRequest", + fields={ + "epg_data_id": serializers.IntegerField(help_text="EPG data ID to link") }, ), responses={200: "EPG data linked and refresh triggered"}, @@ -1251,28 +1233,22 @@ class ChannelViewSet(viewsets.ModelViewSet): except Exception as e: return Response({"error": str(e)}, status=400) - @swagger_auto_schema( - method="post", - operation_description=( + @extend_schema( + description=( "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " "The channel will receive the next whole number after the target channel, and all subsequent " "channels will be renumbered accordingly." ), - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "insert_after_id": openapi.Schema( - type=openapi.TYPE_INTEGER, - description="ID of the channel to insert after. Use null to move to the beginning.", - nullable=True, + request=inline_serializer( + name="ReorderChannelRequest", + fields={ + "insert_after_id": serializers.IntegerField( + help_text="ID of the channel to insert after. Use null to move to the beginning.", + required=False, + allow_null=True, ), }, ), - responses={ - 200: "Channel reordered successfully", - 404: "Channel not found", - 400: "Invalid request", - }, ) @action(detail=True, methods=["post"], url_path="reorder") def reorder(self, request, pk=None): @@ -1340,25 +1316,23 @@ class ChannelViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) - @swagger_auto_schema( - method="post", - operation_description="Associate multiple channels with EPG data without triggering a full refresh", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "associations": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "channel_id": openapi.Schema(type=openapi.TYPE_INTEGER), - "epg_data_id": openapi.Schema(type=openapi.TYPE_INTEGER), + @extend_schema( + methods=["POST"], + description="Associate multiple channels with EPG data without triggering a full refresh", + request=inline_serializer( + name="BatchSetEpgRequest", + fields={ + "associations": serializers.ListField( + child=inline_serializer( + name="EpgAssociation", + fields={ + "channel_id": serializers.IntegerField(), + "epg_data_id": serializers.IntegerField(), }, ), ) }, ), - responses={200: "EPG data linked for multiple channels"}, ) @action(detail=False, methods=["post"], url_path="batch-set-epg") def batch_set_epg(self, request): @@ -1456,20 +1430,17 @@ class BulkDeleteStreamsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete streams by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["stream_ids"], - properties={ - "stream_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Stream IDs to delete", + @extend_schema( + description="Bulk delete streams by ID", + request=inline_serializer( + name="BulkDeleteStreamsRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Stream IDs to delete", ) }, ), - responses={204: "Streams deleted"}, ) def delete(self, request, *args, **kwargs): stream_ids = request.data.get("stream_ids", []) @@ -1492,20 +1463,17 @@ class BulkDeleteChannelsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete channels by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["channel_ids"], - properties={ - "channel_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Channel IDs to delete", + @extend_schema( + description="Bulk delete channels by ID", + request=inline_serializer( + name="BulkDeleteChannelsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to delete", ) }, ), - responses={204: "Channels deleted"}, ) def delete(self, request): channel_ids = request.data.get("channel_ids", []) @@ -1527,20 +1495,17 @@ class BulkDeleteLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk delete logos by ID", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=["logo_ids"], - properties={ - "logo_ids": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_INTEGER), - description="Logo IDs to delete", + @extend_schema( + description="Bulk delete logos by ID", + request=inline_serializer( + name="BulkDeleteLogosRequest", + fields={ + "logo_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Logo IDs to delete", ) }, ), - responses={204: "Logos deleted"}, ) def delete(self, request): logo_ids = request.data.get("logo_ids", []) @@ -1597,19 +1562,18 @@ class CleanupUnusedLogosAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Delete all channel logos that are not used by any channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "delete_files": openapi.Schema( - type=openapi.TYPE_BOOLEAN, - description="Whether to delete local logo files from disk", - default=False + @extend_schema( + description="Delete all channel logos that are not used by any channels", + request=inline_serializer( + name="CleanupUnusedLogosRequest", + fields={ + "delete_files": serializers.BooleanField( + help_text="Whether to delete local logo files from disk", + default=False, + required=False, ) }, ), - responses={200: "Cleanup completed"}, ) def post(self, request): """Delete all channel logos with no channel associations""" @@ -2019,29 +1983,9 @@ class BulkUpdateChannelMembershipAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", - request_body=BulkChannelProfileMembershipSerializer, - responses={ - 200: openapi.Response( - description="Channels updated successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "status": openapi.Schema(type=openapi.TYPE_STRING, example="success"), - "updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"), - "created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"), - "invalid_channels": openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description="List of channel IDs that don't exist" - ), - }, - ), - ), - 400: "Invalid request data", - 404: "Profile not found", - }, + @extend_schema( + description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", + request=BulkChannelProfileMembershipSerializer, ) def patch(self, request, profile_id): """Bulk enable or disable channels for a specific profile""" @@ -2406,71 +2350,24 @@ class SeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="List all series rules", - operation_description="Retrieve all configured DVR series recording rules.", - responses={ - 200: openapi.Response( - description="List of series rules", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), - 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], description='Recording mode: all episodes or new only'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), - }, - ), - description='List of series recording rules' - ), - }, - ), - ), - }, + @extend_schema( + summary="List all series rules", + description="Retrieve all configured DVR series recording rules.", ) def get(self, request): return Response({"rules": CoreSettings.get_dvr_series_rules()}) - @swagger_auto_schema( - operation_summary="Create or update a series rule", - operation_description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=['tvg_id'], - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID'), - 'mode': openapi.Schema(type=openapi.TYPE_STRING, enum=['all', 'new'], default='all', description='all: record all episodes, new: record only new episodes'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title'), + @extend_schema( + summary="Create or update a series rule", + description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request=inline_serializer( + name="SeriesRuleRequest", + fields={ + 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), + 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'), + 'title': serializers.CharField(help_text='Series title', required=False), }, ), - responses={ - 200: openapi.Response( - description="Series rule created/updated successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), - 'mode': openapi.Schema(type=openapi.TYPE_STRING), - 'title': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - description='Updated list of all rules' - ), - }, - ), - ), - 400: openapi.Response(description="Bad request (missing tvg_id or invalid mode)"), - }, ) def post(self, request): data = request.data or {} @@ -2504,35 +2401,12 @@ class DeleteSeriesRuleAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Delete a series rule", - operation_description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", - manual_parameters=[ - openapi.Parameter('tvg_id', openapi.IN_PATH, type=openapi.TYPE_STRING, required=True, description='Channel TVG ID'), + @extend_schema( + summary="Delete a series rule", + description="Remove a series recording rule by TVG ID. This does not remove already scheduled recordings.", + parameters=[ + OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'), ], - responses={ - 200: openapi.Response( - description="Series rule deleted successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'rules': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING), - 'mode': openapi.Schema(type=openapi.TYPE_STRING), - 'title': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - description='Updated list of all rules' - ), - }, - ), - ), - }, ) def delete(self, request, tvg_id): tvg_id = unquote(str(tvg_id or "")) @@ -2548,26 +2422,15 @@ class EvaluateSeriesRulesAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Evaluate series rules", - operation_description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated.'), + @extend_schema( + summary="Evaluate series rules", + description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request=inline_serializer( + name="EvaluateSeriesRulesRequest", + fields={ + "tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."), }, ), - responses={ - 200: openapi.Response( - description="Evaluation completed successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - }, - ), - ), - }, ) def post(self, request): tvg_id = request.data.get("tvg_id") @@ -2590,31 +2453,17 @@ class BulkRemoveSeriesRecordingsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_summary="Bulk remove scheduled recordings for a series", - operation_description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - required=['tvg_id'], - properties={ - 'tvg_id': openapi.Schema(type=openapi.TYPE_STRING, description='Channel TVG ID (required)'), - 'title': openapi.Schema(type=openapi.TYPE_STRING, description='Series title - when scope=title, only recordings matching this title are removed'), - 'scope': openapi.Schema(type=openapi.TYPE_STRING, enum=['title', 'channel'], default='title', description='title: remove only matching title on channel, channel: remove all future recordings on channel'), + @extend_schema( + summary="Bulk remove scheduled recordings for a series", + description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request=inline_serializer( + name="BulkRemoveSeriesRecordingsRequest", + fields={ + "tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"), + "title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"), + "scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"), }, ), - responses={ - 200: openapi.Response( - description="Recordings removed successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'success': openapi.Schema(type=openapi.TYPE_BOOLEAN), - 'removed': openapi.Schema(type=openapi.TYPE_INTEGER, description='Number of recordings deleted'), - }, - ), - ), - 400: openapi.Response(description="Bad request (missing tvg_id)"), - }, ) def post(self, request): from django.utils import timezone diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 319368d0..00f7403f 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -1,10 +1,10 @@ import logging, os -from rest_framework import viewsets, status +from rest_framework import viewsets, status, serializers from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.decorators import action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes from django.utils import timezone from datetime import timedelta from .models import EPGSource, ProgramData, EPGData # Added ProgramData @@ -122,8 +122,8 @@ class EPGGridAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", + @extend_schema( + description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", responses={200: ProgramDataSerializer(many=True)}, ) def get(self, request, format=None): @@ -371,9 +371,8 @@ class EPGImportAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers an EPG data import", - responses={202: "EPG data import initiated"}, + @extend_schema( + description="Triggers an EPG data import", ) def post(self, request, format=None): logger.info("EPGImportAPIView: Received request to import EPG data.") @@ -435,20 +434,20 @@ class CurrentProgramsAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Get currently playing programs for specified channels or all channels", - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'channel_ids': openapi.Schema( - type=openapi.TYPE_ARRAY, - items=openapi.Schema(type=openapi.TYPE_INTEGER), - description="Array of channel IDs. If null or omitted, returns all channels with current programs.", - nullable=True, - ) + @extend_schema( + description="Get currently playing programs for specified channels or all channels", + request=inline_serializer( + name="CurrentProgramsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True, + help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + ), }, ), - responses={200: openapi.Response('Current programs', ProgramDataSerializer(many=True))}, + responses={200: ProgramDataSerializer(many=True)}, ) def post(self, request, format=None): # Get channel IDs from request body diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py index 8f1609d4..2227170e 100644 --- a/apps/hdhr/api_views.py +++ b/apps/hdhr/api_views.py @@ -4,8 +4,8 @@ from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse import logging -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.db import models from apps.channels.models import Channel, ChannelProfile, Stream @@ -47,9 +47,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request, profile=None): uri_parts = ["hdhr"] @@ -100,9 +99,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request, profile=None): if profile is not None: @@ -141,9 +139,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request, profile=None): data = { @@ -159,9 +156,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py index 40823259..f9dd42d2 100644 --- a/apps/hdhr/views.py +++ b/apps/hdhr/views.py @@ -3,8 +3,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from apps.accounts.permissions import Authenticated, permission_classes_by_action from django.http import JsonResponse, HttpResponseForbidden, HttpResponse -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from apps.channels.models import Channel from .models import HDHRDevice @@ -42,9 +42,8 @@ class HDHRDeviceViewSet(viewsets.ModelViewSet): class DiscoverAPIView(APIView): """Returns device discovery information""" - @swagger_auto_schema( - operation_description="Retrieve HDHomeRun device discovery information", - responses={200: openapi.Response("HDHR Discovery JSON")}, + @extend_schema( + description="Retrieve HDHomeRun device discovery information", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") @@ -81,9 +80,8 @@ class DiscoverAPIView(APIView): class LineupAPIView(APIView): """Returns available channel lineup""" - @swagger_auto_schema( - operation_description="Retrieve the available channel lineup", - responses={200: openapi.Response("Channel Lineup JSON")}, + @extend_schema( + description="Retrieve the available channel lineup", ) def get(self, request): channels = Channel.objects.all().order_by("channel_number") @@ -102,9 +100,8 @@ class LineupAPIView(APIView): class LineupStatusAPIView(APIView): """Returns the current status of the HDHR lineup""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun lineup status", - responses={200: openapi.Response("Lineup Status JSON")}, + @extend_schema( + description="Retrieve the HDHomeRun lineup status", ) def get(self, request): data = { @@ -120,9 +117,8 @@ class LineupStatusAPIView(APIView): class HDHRDeviceXMLAPIView(APIView): """Returns HDHomeRun device configuration in XML""" - @swagger_auto_schema( - operation_description="Retrieve the HDHomeRun device XML configuration", - responses={200: openapi.Response("HDHR Device XML")}, + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", ) def get(self, request): base_url = request.build_absolute_uri("/hdhr/").rstrip("/") diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 1f16f20f..73331f7a 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -6,8 +6,8 @@ from apps.accounts.permissions import ( permission_classes_by_action, permission_classes_by_method, ) -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.core.cache import cache @@ -357,9 +357,8 @@ class RefreshM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of all active M3U accounts", - responses={202: "M3U refresh initiated"}, + @extend_schema( + description="Triggers a refresh of all active M3U accounts", ) def post(self, request, format=None): refresh_m3u_accounts.delay() @@ -380,9 +379,8 @@ class RefreshSingleM3UAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of a single M3U account", - responses={202: "M3U account refresh initiated"}, + @extend_schema( + description="Triggers a refresh of a single M3U account", ) def post(self, request, account_id, format=None): refresh_single_m3u_account.delay(account_id) @@ -406,9 +404,8 @@ class RefreshAccountInfoAPIView(APIView): except KeyError: return [Authenticated()] - @swagger_auto_schema( - operation_description="Triggers a refresh of account information for a specific M3U profile", - responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"}, + @extend_schema( + description="Triggers a refresh of account information for a specific M3U profile", ) def post(self, request, profile_id, format=None): try: diff --git a/core/api_views.py b/core/api_views.py index 30829174..44001f95 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -9,8 +9,8 @@ from rest_framework.views import APIView from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import api_view, permission_classes, action -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes from .models import ( UserAgent, StreamProfile, @@ -241,10 +241,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): -@swagger_auto_schema( - method="get", - operation_description="Endpoint for environment details", - responses={200: "Environment variables"}, +@extend_schema( + description="Endpoint for environment details", ) @api_view(["GET"]) @permission_classes([Authenticated]) @@ -314,10 +312,8 @@ def environment(request): ) -@swagger_auto_schema( - method="get", - operation_description="Get application version information", - responses={200: "Version information"}, +@extend_schema( + description="Get application version information", ) @api_view(["GET"]) @@ -333,10 +329,8 @@ def version(request): ) -@swagger_auto_schema( - method="post", - operation_description="Trigger rehashing of all streams", - responses={200: "Rehash task started"}, +@extend_schema( + description="Trigger rehashing of all streams", ) @api_view(["POST"]) @permission_classes([Authenticated]) @@ -383,9 +377,8 @@ class TimezoneListView(APIView): def get_permissions(self): return [Authenticated()] - @swagger_auto_schema( - operation_description="Get list of all supported timezones", - responses={200: openapi.Response('List of timezones with grouping by region')} + @extend_schema( + description="Get list of all supported timezones", ) def get(self, request): import pytz diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 1a9a1a44..b65ee2d0 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -32,7 +32,7 @@ INSTALLED_APPS = [ "apps.vod.apps.VODConfig", "core", "daphne", - "drf_yasg", + "drf_spectacular", "channels", "django.contrib.admin", "django.contrib.auth", @@ -151,7 +151,7 @@ AUTH_PASSWORD_VALIDATORS = [ ] REST_FRAMEWORK = { - "DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_RENDERER_CLASSES": [ "rest_framework.renderers.JSONRenderer", "rest_framework.renderers.BrowsableAPIRenderer", @@ -162,10 +162,22 @@ REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } -SWAGGER_SETTINGS = { - "SECURITY_DEFINITIONS": { - "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} - } +SPECTACULAR_SETTINGS = { + "TITLE": "Dispatcharr API", + "DESCRIPTION": "API documentation for Dispatcharr", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SECURITY": [{"BearerAuth": []}], + "COMPONENTS": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Enter your JWT access token. The 'Bearer ' prefix is added automatically.", + } + } + }, } LANGUAGE_CODE = "en-us" diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 890d0c2d..1ab4ebfb 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -3,32 +3,20 @@ from django.urls import path, include, re_path from django.conf import settings from django.conf.urls.static import static from django.views.generic import TemplateView, RedirectView -from rest_framework import permissions -from drf_yasg.views import get_schema_view -from drf_yasg import openapi from .routing import websocket_urlpatterns from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv from apps.proxy.ts_proxy.views import stream_xc from apps.output.views import xc_movie_stream, xc_series_stream -# Define schema_view for Swagger -schema_view = get_schema_view( - openapi.Info( - title="Dispatcharr API", - default_version="v1", - description="API documentation for Dispatcharr", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@dispatcharr.local"), - license=openapi.License(name="Creative Commons by-nc-sa"), - ), - public=True, - permission_classes=(permissions.AllowAny,), -) - urlpatterns = [ # API Routes path("api/", include(("apps.api.urls", "api"), namespace="api")), path("api", RedirectView.as_view(url="/api/", permanent=True)), + # Swagger redirects (Swagger UI is served at /api/swagger/) + path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), + path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), # Admin path("admin", RedirectView.as_view(url="/admin/", permanent=True)), path("admin/", admin.site.urls), @@ -68,12 +56,6 @@ urlpatterns = [ name="xc_series_stream", ), - re_path(r"^swagger/?$", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), - # ReDoc UI - path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), - # Optionally, serve the raw Swagger JSON - path("swagger.json", schema_view.without_ui(cache_timeout=0), name="schema-json"), - # VOD proxy is now handled by the main proxy URLs above # Catch-all routes should always be last path("", TemplateView.as_view(template_name="index.html")), # React entry point From bd1e0e8f079c4e1a7a004b448e1c80fc1db20a56 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 14:36:57 -0600 Subject: [PATCH 12/63] Bug fix: Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). --- CHANGELOG.md | 4 ++++ dispatcharr/urls.py | 6 +++--- docker/nginx.conf | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c3abd4..960302d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection +### Fixed + +- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). + ## [0.18.1] - 2026-01-27 ### Fixed diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py index 1ab4ebfb..092fb0cb 100644 --- a/dispatcharr/urls.py +++ b/dispatcharr/urls.py @@ -17,9 +17,6 @@ urlpatterns = [ path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), - # Admin - path("admin", RedirectView.as_view(url="/admin/", permanent=True)), - path("admin/", admin.site.urls), # Outputs path("output", RedirectView.as_view(url="/output/", permanent=True)), path("output/", include(("apps.output.urls", "output"), namespace="output")), @@ -55,6 +52,9 @@ urlpatterns = [ xc_series_stream, name="xc_series_stream", ), + # Admin + path("admin", RedirectView.as_view(url="/admin/", permanent=True)), + path("admin/", admin.site.urls), # VOD proxy is now handled by the main proxy URLs above # Catch-all routes should always be last diff --git a/docker/nginx.conf b/docker/nginx.conf index 406d587c..e08d08f2 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -59,7 +59,7 @@ server { } # admin disabled when not in dev mode - location /admin { + location ~ ^/admin/?$ { return 301 /login; } From 5cb0b7dd3b3e07fbe9e82dcd3a57e087889a7429 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 27 Jan 2026 14:52:12 -0600 Subject: [PATCH 13/63] Add pytz to requirements. Was included as a dependency of drf-yasg before. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1f3c63aa..6858baec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ djangorestframework==3.16.1 requests==2.32.5 psutil==7.1.3 pillow -drf-spectacular>=0.28.0 +drf-spectacular>=0.29.0 streamlink python-vlc yt-dlp @@ -18,6 +18,7 @@ m3u8 rapidfuzz==3.14.3 regex # Required by transformers but also used for advanced regex features tzlocal +pytz # PyTorch dependencies (CPU only) --extra-index-url https://download.pytorch.org/whl/cpu/ From bd4c9e1d9505bf817daa66a50edeb5ec6b93552b Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:39:04 -0800 Subject: [PATCH 14/63] Added unit tests --- .../hooks/__tests__/useLocalStorage.test.jsx | 110 +++ .../hooks/__tests__/useSmartLogos.test.jsx | 297 ++++++ frontend/src/store/__tests__/auth.test.jsx | 479 +++++++++ .../src/store/__tests__/channels.test.jsx | 442 +++++++++ .../store/__tests__/channelsTable.test.jsx | 295 ++++++ frontend/src/store/__tests__/epgs.test.jsx | 687 +++++++++++++ frontend/src/store/__tests__/logos.test.jsx | 916 ++++++++++++++++++ .../src/store/__tests__/playlists.test.jsx | 266 +++++ frontend/src/store/__tests__/plugins.test.jsx | 234 +++++ .../src/store/__tests__/settings.test.jsx | 204 ++++ .../store/__tests__/streamProfiles.test.jsx | 265 +++++ frontend/src/store/__tests__/streams.test.jsx | 289 ++++++ .../src/store/__tests__/useVODStore.test.jsx | 755 +++++++++++++++ .../store/__tests__/useVideoStore.test.jsx | 182 ++++ .../src/store/__tests__/userAgents.test.jsx | 277 ++++++ frontend/src/store/__tests__/users.test.jsx | 258 +++++ .../src/store/__tests__/vodLogos.test.jsx | 480 +++++++++ .../src/store/__tests__/warnings.test.jsx | 195 ++++ 18 files changed, 6631 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useLocalStorage.test.jsx create mode 100644 frontend/src/hooks/__tests__/useSmartLogos.test.jsx create mode 100644 frontend/src/store/__tests__/auth.test.jsx create mode 100644 frontend/src/store/__tests__/channels.test.jsx create mode 100644 frontend/src/store/__tests__/channelsTable.test.jsx create mode 100644 frontend/src/store/__tests__/epgs.test.jsx create mode 100644 frontend/src/store/__tests__/logos.test.jsx create mode 100644 frontend/src/store/__tests__/playlists.test.jsx create mode 100644 frontend/src/store/__tests__/plugins.test.jsx create mode 100644 frontend/src/store/__tests__/settings.test.jsx create mode 100644 frontend/src/store/__tests__/streamProfiles.test.jsx create mode 100644 frontend/src/store/__tests__/streams.test.jsx create mode 100644 frontend/src/store/__tests__/useVODStore.test.jsx create mode 100644 frontend/src/store/__tests__/useVideoStore.test.jsx create mode 100644 frontend/src/store/__tests__/userAgents.test.jsx create mode 100644 frontend/src/store/__tests__/users.test.jsx create mode 100644 frontend/src/store/__tests__/vodLogos.test.jsx create mode 100644 frontend/src/store/__tests__/warnings.test.jsx diff --git a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx new file mode 100644 index 00000000..8be21e28 --- /dev/null +++ b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx @@ -0,0 +1,110 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useLocalStorage from '../useLocalStorage'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +global.localStorage = localStorageMock; + +// Mock console.error to avoid cluttering test output +global.console.error = vi.fn(); + +describe('useLocalStorage', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('should initialize with default value when localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('defaultValue'); + }); + + it('should initialize with value from localStorage if available', () => { + localStorageMock.setItem('testKey', JSON.stringify('storedValue')); + + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('storedValue'); + }); + + it('should update localStorage when value changes', () => { + const { result } = renderHook(() => useLocalStorage('testKey', 'initial')); + + act(() => { + result.current[1]('updated'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated')); + expect(result.current[0]).toBe('updated'); + }); + + it('should handle complex objects', () => { + const complexObject = { name: 'test', count: 42, nested: { value: true } }; + + const { result } = renderHook(() => useLocalStorage('testKey', complexObject)); + + act(() => { + result.current[1]({ name: 'updated', count: 100 }); + }); + + expect(result.current[0]).toEqual({ name: 'updated', count: 100 }); + }); + + it('should handle errors when reading from localStorage', () => { + localStorageMock.getItem.mockImplementationOnce(() => { + throw new Error('Read error'); + }); + + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('defaultValue'); + expect(console.error).toHaveBeenCalledWith( + 'Error reading key "testKey":', + expect.any(Error) + ); + }); + + it('should handle errors when writing to localStorage', () => { + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Write error'); + }); + + const { result } = renderHook(() => useLocalStorage('testKey', 'initial')); + + act(() => { + result.current[1]('updated'); + }); + + expect(console.error).toHaveBeenCalledWith( + 'Error saving setting: testKey:', + expect.any(Error) + ); + }); + + it('should handle invalid JSON in localStorage', () => { + localStorageMock.getItem.mockReturnValueOnce('invalid json{'); + + const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + + expect(result.current[0]).toBe('defaultValue'); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx new file mode 100644 index 00000000..d8ef2711 --- /dev/null +++ b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx @@ -0,0 +1,297 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos'; +import useLogosStore from '../../store/logos'; + +// Mock the logos store +vi.mock('../../store/logos'); + +describe('useSmartLogos', () => { + describe('useLogoSelection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with empty state', () => { + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogos: vi.fn(), + })); + + const { result } = renderHook(() => useLogoSelection()); + + expect(result.current.logos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLogos).toBe(false); + }); + + it('should load logos when ensureLogosLoaded is called', async () => { + const mockFetchLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogos: mockFetchLogos, + })); + + const { result } = renderHook(() => useLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchLogos).toHaveBeenCalledTimes(1); + }); + + it('should not reload logos if already loaded', async () => { + const mockFetchLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: { logo1: { id: 'logo1' } }, + fetchLogos: mockFetchLogos, + })); + + const { result } = renderHook(() => useLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchLogos).toHaveBeenCalledTimes(1); + }); + + it('should handle errors when fetching logos', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogos: mockFetchLogos, + })); + + const { result } = renderHook(() => useLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load logos for selection:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('should indicate hasLogos when logos are present', () => { + useLogosStore.mockImplementation((selector) => selector({ + logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } }, + fetchLogos: vi.fn(), + })); + + const { result } = renderHook(() => useLogoSelection()); + + expect(result.current.hasLogos).toBe(true); + }); + }); + + describe('useChannelLogoSelection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with channel logos state', () => { + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: vi.fn(), + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + expect(result.current.logos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLogos).toBe(false); + }); + + it('should load channel logos when ensureLogosLoaded is called', async () => { + const mockFetchChannelLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchChannelLogos).toHaveBeenCalledTimes(1); + }); + + it('should not reload if already loaded', async () => { + const mockFetchChannelLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: { logo1: { id: 'logo1' } }, + hasLoadedChannelLogos: true, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchChannelLogos).not.toHaveBeenCalled(); + }); + + it('should not load if backgroundLoading is true', async () => { + const mockFetchChannelLogos = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: true, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(mockFetchChannelLogos).not.toHaveBeenCalled(); + }); + + it('should handle errors when fetching channel logos', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + })); + + const { result } = renderHook(() => useChannelLogoSelection()); + + await act(async () => { + await result.current.ensureLogosLoaded(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load channel logos:', + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('useLogosById', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should initialize with empty logos', () => { + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: vi.fn(), + })); + + const { result } = renderHook(() => useLogosById([])); + + expect(result.current.logos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.missingLogos).toBe(0); + }); + + it('should fetch missing logos by IDs', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1', 'logo2'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']); + }); + }); + + it('should not fetch logos that are already loaded', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: { logo1: { id: 'logo1' } }, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1', 'logo2'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo2']); + }); + }); + + it('should handle errors when fetching logos by IDs', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1'])); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to load logos by IDs:', + expect.any(Error) + ); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('should filter out null/undefined IDs', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + renderHook(() => useLogosById(['logo1', null, undefined, 'logo2'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo1', 'logo2']); + }); + }); + + it('should not refetch the same IDs multiple times', async () => { + const mockFetchLogosByIds = vi.fn().mockResolvedValue(); + useLogosStore.mockImplementation((selector) => selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + })); + + const { rerender } = renderHook(() => useLogosById(['logo1'])); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + }); + + rerender(); + + await waitFor(() => { + expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx new file mode 100644 index 00000000..4ae99e4b --- /dev/null +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -0,0 +1,479 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import useAuthStore from '../auth'; +import useSettingsStore from '../settings'; +import useChannelsStore from '../channels'; +import usePlaylistsStore from '../playlists'; +import useEPGsStore from '../epgs'; +import useStreamProfilesStore from '../streamProfiles'; +import useUserAgentsStore from '../userAgents'; +import useUsersStore from '../users'; +import API from '../../api'; +import { USER_LEVELS } from '../../constants'; + +// Mock all store dependencies +vi.mock('../settings'); +vi.mock('../channels'); +vi.mock('../playlists'); +vi.mock('../epgs'); +vi.mock('../streamProfiles'); +vi.mock('../userAgents'); +vi.mock('../users'); +vi.mock('../../api'); + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + clear: vi.fn(() => { + store = {}; + }), + }; +})(); + +global.localStorage = localStorageMock; + +// Mock console methods +global.console.error = vi.fn(); + +// Helper to create a mock JWT token +const createMockToken = (expiresInSeconds = 3600) => { + const now = Math.floor(Date.now() / 1000); + const exp = now + expiresInSeconds; + const payload = btoa(JSON.stringify({ exp })); + return `header.${payload}.signature`; +}; + +describe('useAuthStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + + // Setup default store mocks + useSettingsStore.mockImplementation((selector) => selector({ + fetchSettings: vi.fn().mockResolvedValue(), + })); + + useChannelsStore.mockImplementation((selector) => selector({ + fetchChannels: vi.fn().mockResolvedValue(), + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + })); + + usePlaylistsStore.mockImplementation((selector) => selector({ + fetchPlaylists: vi.fn().mockResolvedValue(), + })); + + useEPGsStore.mockImplementation((selector) => selector({ + fetchEPGs: vi.fn().mockResolvedValue(), + fetchEPGData: vi.fn().mockResolvedValue(), + })); + + useStreamProfilesStore.mockImplementation((selector) => selector({ + fetchProfiles: vi.fn().mockResolvedValue(), + })); + + useUserAgentsStore.mockImplementation((selector) => selector({ + fetchUserAgents: vi.fn().mockResolvedValue(), + })); + + useUsersStore.mockImplementation((selector) => selector({ + fetchUsers: vi.fn().mockResolvedValue(), + })); + }); + + afterEach(() => { + // Reset the store state + const { setState } = useAuthStore; + if (setState) { + setState({ + isAuthenticated: false, + isInitialized: false, + needsSuperuser: false, + user: { + username: '', + email: '', + user_level: '', + }, + isLoading: false, + error: null, + accessToken: null, + refreshToken: null, + tokenExpiration: null, + superuserExists: true, + }); + } + }); + + describe('Initial State', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useAuthStore()); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.isInitialized).toBe(false); + expect(result.current.needsSuperuser).toBe(false); + expect(result.current.user).toEqual({ + username: '', + email: '', + user_level: '', + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.superuserExists).toBe(true); + }); + }); + + describe('login', () => { + it('should successfully login and store tokens', async () => { + const mockAccessToken = createMockToken(); + const mockRefreshToken = createMockToken(86400); + + API.login.mockResolvedValue({ + access: mockAccessToken, + refresh: mockRefreshToken, + }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'password' }); + }); + + expect(API.login).toHaveBeenCalledWith('testuser', 'password'); + expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken); + expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken); + expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number)); + }); + + it('should handle login failure', async () => { + API.login.mockRejectedValue(new Error('Invalid credentials')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'wrong' }); + }); + + expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + }); + }); + + describe('getRefreshToken', () => { + it('should refresh token successfully', async () => { + const mockNewAccessToken = createMockToken(); + localStorageMock.getItem.mockReturnValue('old-refresh-token'); + + API.refreshToken.mockResolvedValue({ + access: mockNewAccessToken, + }); + + const { result } = renderHook(() => useAuthStore()); + + let newToken; + await act(async () => { + newToken = await result.current.getRefreshToken(); + }); + + expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token'); + expect(newToken).toBe(mockNewAccessToken); + expect(result.current.isAuthenticated).toBe(true); + expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken); + }); + + it('should return false if no refresh token exists', async () => { + localStorageMock.getItem.mockReturnValue(null); + + const { result } = renderHook(() => useAuthStore()); + + let response; + await act(async () => { + response = await result.current.getRefreshToken(); + }); + + expect(response).toBe(false); + expect(API.refreshToken).not.toHaveBeenCalled(); + }); + + it('should logout on refresh token failure', async () => { + localStorageMock.getItem.mockReturnValue('invalid-refresh-token'); + API.refreshToken.mockRejectedValue(new Error('Invalid token')); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.getRefreshToken(); + }); + + expect(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error)); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration'); + }); + }); + + describe('getToken', () => { + it('should return valid access token if not expired', async () => { + const mockToken = createMockToken(3600); + const now = Math.floor(Date.now() / 1000); + + localStorageMock.getItem.mockImplementation((key) => { + if (key === 'tokenExpiration') return (now + 1800).toString(); + if (key === 'accessToken') return mockToken; + return null; + }); + + const { result } = renderHook(() => useAuthStore()); + + let token; + await act(async () => { + token = await result.current.getToken(); + }); + + expect(token).toBe(mockToken); + }); + + it('should refresh token if expired', async () => { + const now = Math.floor(Date.now() / 1000); + const mockNewToken = createMockToken(); + + localStorageMock.getItem.mockImplementation((key) => { + if (key === 'tokenExpiration') return (now - 100).toString(); + if (key === 'refreshToken') return 'refresh-token'; + return null; + }); + + API.refreshToken.mockResolvedValue({ + access: mockNewToken, + }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.getToken(); + }); + + expect(API.refreshToken).toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('should clear tokens and call logout API', async () => { + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(API.logout).toHaveBeenCalled(); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration'); + }); + + it('should continue logout even if API call fails', async () => { + API.logout.mockRejectedValue(new Error('API error')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error)); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.removeItem).toHaveBeenCalled(); + }); + }); + + describe('initializeAuth', () => { + it('should initialize auth with valid refresh token', async () => { + const mockToken = createMockToken(); + localStorageMock.getItem.mockReturnValue('valid-refresh-token'); + API.refreshToken.mockResolvedValue({ access: mockToken }); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(true); + }); + + it('should return false if no refresh token exists', async () => { + localStorageMock.getItem.mockReturnValue(null); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + }); + + describe('initData', () => { + const fetchSettings = vi.fn().mockResolvedValue(); + const fetchChannels = vi.fn().mockResolvedValue(); + const fetchChannelGroups = vi.fn().mockResolvedValue(); + const fetchChannelProfiles = vi.fn().mockResolvedValue(); + const fetchPlaylists = vi.fn().mockResolvedValue(); + const fetchEPGs = vi.fn().mockResolvedValue(); + const fetchEPGData = vi.fn().mockResolvedValue(); + const fetchProfiles = vi.fn().mockResolvedValue(); + const fetchUserAgents = vi.fn().mockResolvedValue(); + const fetchUsers = vi.fn().mockResolvedValue(); + + // Mock getState for each store + useSettingsStore.getState = () => ({ fetchSettings }); + useChannelsStore.getState = () => ({ + fetchChannels, + fetchChannelGroups, + fetchChannelProfiles, + }); + usePlaylistsStore.getState = () => ({ fetchPlaylists }); + useEPGsStore.getState = () => ({ fetchEPGs, fetchEPGData }); + useStreamProfilesStore.getState = () => ({ fetchProfiles }); + useUserAgentsStore.getState = () => ({ fetchUserAgents }); + useUsersStore.getState = () => ({ fetchUsers }); + + it('should initialize data for admin user', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).toHaveBeenCalled(); + expect(result.current.user).toEqual(mockUser); + expect(result.current.isAuthenticated).toBe(true); + expect(fetchSettings).toHaveBeenCalled(); + expect(fetchChannels).toHaveBeenCalled(); + expect(fetchUsers).toHaveBeenCalled(); + }); + + + it('should not fetch users for non-admin user', async () => { + const mockUser = { + username: 'reseller', + email: 'reseller@test.com', + user_level: USER_LEVELS.RESELLER, + }; + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(fetchUsers).not.toHaveBeenCalled(); + }); + + it('should throw error for unauthorized user level', async () => { + const mockUser = { + username: 'streamer', + email: 'streamer@test.com', + user_level: USER_LEVELS.STREAMER, + }; + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await expect( + act(async () => { + await result.current.initData(); + }) + ).rejects.toThrow('Unauthorized'); + }); + + it('should handle errors during data initialization', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + API.me.mockResolvedValue(mockUser); + + const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed')); + + useChannelsStore.getState = vi.fn(() => ({ + fetchChannels, + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + })); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error)); + }); + }); + + describe('setUser', () => { + it('should update user state', () => { + const { result } = renderHook(() => useAuthStore()); + const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN }; + + act(() => { + result.current.setUser(newUser); + }); + + expect(result.current.user).toEqual(newUser); + }); + }); + + describe('setIsAuthenticated', () => { + it('should update authentication state', () => { + const { result } = renderHook(() => useAuthStore()); + + act(() => { + result.current.setIsAuthenticated(true); + }); + + expect(result.current.isAuthenticated).toBe(true); + }); + }); + + describe('setSuperuserExists', () => { + it('should update superuser exists state', () => { + const { result } = renderHook(() => useAuthStore()); + + act(() => { + result.current.setSuperuserExists(false); + }); + + expect(result.current.superuserExists).toBe(false); + }); + }); +}); diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx new file mode 100644 index 00000000..6b452549 --- /dev/null +++ b/frontend/src/store/__tests__/channels.test.jsx @@ -0,0 +1,442 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useChannelsStore from '../channels'; +import api from '../../api'; +import { showNotification } from '../../utils/notificationUtils'; + +// Mock dependencies +vi.mock('../../api'); +vi.mock('../../utils/notificationUtils'); + +describe('useChannelsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset store state between tests + useChannelsStore.setState({ + channels: {}, + channelsByUUID: {}, + channelGroups: {}, + profiles: { 0: { id: '0', name: 'All', channels: new Set() } }, + selectedProfileId: '0', + channelsPageSelection: [], + stats: {}, + activeChannels: {}, + activeClients: {}, + recordings: [], + recurringRules: [], + isLoading: false, + error: null, + forceUpdate: 0, + }); + }); + + describe('fetchChannels', () => { + it('should fetch and store channels successfully', async () => { + const mockChannels = [ + { id: 1, uuid: 'uuid-1', name: 'Channel 1' }, + { id: 2, uuid: 'uuid-2', name: 'Channel 2' }, + ]; + api.getChannels.mockResolvedValue(mockChannels); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannels(); + }); + + expect(api.getChannels).toHaveBeenCalledOnce(); + expect(result.current.channels).toEqual({ + 1: mockChannels[0], + 2: mockChannels[1], + }); + expect(result.current.channelsByUUID).toEqual({ + 'uuid-1': 1, + 'uuid-2': 2, + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('should handle fetch error', async () => { + const errorMessage = 'Network error'; + api.getChannels.mockRejectedValue(new Error(errorMessage)); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannels(); + }); + + expect(result.current.error).toBe(errorMessage); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('fetchChannelGroups', () => { + it('should fetch and process channel groups', async () => { + const mockGroups = [ + { id: 1, name: 'Group 1', channel_count: 5, m3u_account_count: 0 }, + { id: 2, name: 'Group 2', channel_count: 0, m3u_account_count: 2 }, + ]; + api.getChannelGroups.mockResolvedValue(mockGroups); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannelGroups(); + }); + + expect(api.getChannelGroups).toHaveBeenCalledOnce(); + expect(result.current.channelGroups[1]).toMatchObject({ + hasChannels: true, + hasM3UAccounts: false, + canEdit: true, + canDelete: false, + }); + expect(result.current.channelGroups[2]).toMatchObject({ + hasChannels: false, + hasM3UAccounts: true, + canEdit: false, + canDelete: false, + }); + }); + }); + + describe('fetchChannelProfiles', () => { + it('should fetch and process channel profiles', async () => { + const mockProfiles = [ + { id: '1', name: 'Profile 1', channels: [1, 2, 3] }, + ]; + api.getChannelProfiles.mockResolvedValue(mockProfiles); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchChannelProfiles(); + }); + + expect(api.getChannelProfiles).toHaveBeenCalledOnce(); + expect(result.current.profiles['1'].channels).toBeInstanceOf(Set); + expect(result.current.profiles['1'].channels.has(1)).toBe(true); + }); + }); + + describe('addChannel', () => { + it('should add a new channel', async () => { + const newChannel = { id: 3, uuid: 'uuid-3', name: 'Channel 3' }; + api.getChannelProfiles.mockResolvedValue([]); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + result.current.addChannel(newChannel); + }); + + expect(result.current.channels[3]).toEqual(newChannel); + expect(result.current.channelsByUUID['uuid-3']).toBe(3); + }); + }); + + describe('updateChannel', () => { + it('should update an existing channel', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channels: { 1: { id: 1, uuid: 'uuid-1', name: 'Old Name' } }, + channelsByUUID: { 'uuid-1': 1 }, + }); + }); + + const updatedChannel = { id: 1, uuid: 'uuid-1', name: 'New Name' }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels[1].name).toBe('New Name'); + }); + }); + + describe('removeChannels', () => { + it('should remove channels by IDs', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channels: { + 1: { id: 1, uuid: 'uuid-1' }, + 2: { id: 2, uuid: 'uuid-2' }, + }, + channelsByUUID: { 'uuid-1': 1, 'uuid-2': 2 }, + }); + }); + + act(() => { + result.current.removeChannels([1]); + }); + + expect(result.current.channels[1]).toBeUndefined(); + expect(result.current.channelsByUUID['uuid-1']).toBeUndefined(); + expect(result.current.channels[2]).toBeDefined(); + }); + }); + + describe('channel groups operations', () => { + it('should add a channel group', () => { + const { result } = renderHook(() => useChannelsStore()); + const newGroup = { id: 1, name: 'New Group' }; + + act(() => { + result.current.addChannelGroup(newGroup); + }); + + expect(result.current.channelGroups[1]).toEqual(newGroup); + }); + + it('should update a channel group', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { 1: { id: 1, name: 'Old Name' } }, + }); + }); + + act(() => { + result.current.updateChannelGroup({ id: 1, name: 'Updated Name' }); + }); + + expect(result.current.channelGroups[1].name).toBe('Updated Name'); + }); + + it('should remove a channel group', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { 1: { id: 1, name: 'Group' } }, + }); + }); + + act(() => { + result.current.removeChannelGroup(1); + }); + + expect(result.current.channelGroups[1]).toBeUndefined(); + }); + }); + + describe('profile operations', () => { + it('should add a profile', () => { + const { result } = renderHook(() => useChannelsStore()); + const newProfile = { id: '1', name: 'Profile', channels: [1, 2] }; + + act(() => { + result.current.addProfile(newProfile); + }); + + expect(result.current.profiles['1'].channels).toBeInstanceOf(Set); + }); + + it('should update a profile', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] }); + }); + + expect(result.current.profiles['1'].name).toBe('Updated'); + }); + + it('should remove profiles and reset selected if needed', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + profiles: { '1': { id: '1' }, '2': { id: '2' } }, + selectedProfileId: '1', + }); + }); + + act(() => { + result.current.removeProfiles(['1']); + }); + + expect(result.current.profiles['1']).toBeUndefined(); + expect(result.current.selectedProfileId).toBe('0'); + }); + }); + + describe('updateProfileChannels', () => { + it('should add channels to profile', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + profiles: { '1': { id: '1', channels: new Set([1]) } }, + }); + }); + + act(() => { + result.current.updateProfileChannels([2, 3], '1', true); + }); + + expect(result.current.profiles['1'].channels.has(2)).toBe(true); + expect(result.current.profiles['1'].channels.has(3)).toBe(true); + }); + + it('should remove channels from profile', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } }, + }); + }); + + act(() => { + result.current.updateProfileChannels([2], '1', false); + }); + + expect(result.current.profiles['1'].channels.has(2)).toBe(false); + }); + }); + + describe('setChannelStats', () => { + it('should update stats and show notifications for new channels', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channels: { 1: { id: 1, name: 'Channel 1' } }, + channelsByUUID: { 'uuid-1': 1 }, + stats: { channels: [] }, + }); + }); + + const newStats = { + channels: [ + { channel_id: 'uuid-1', clients: [] }, + ], + }; + + act(() => { + result.current.setChannelStats(newStats); + }); + + expect(result.current.stats).toEqual(newStats); + expect(showNotification).toHaveBeenCalled(); + }); + }); + + describe('recordings operations', () => { + it('should fetch recordings', async () => { + const mockRecordings = [{ id: 1, title: 'Recording 1' }]; + api.getRecordings.mockResolvedValue(mockRecordings); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchRecordings(); + }); + + expect(result.current.recordings).toEqual(mockRecordings); + }); + + it('should remove a recording', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + recordings: [{ id: 1 }, { id: 2 }], + }); + }); + + act(() => { + result.current.removeRecording(1); + }); + + expect(result.current.recordings).toHaveLength(1); + expect(result.current.recordings[0].id).toBe(2); + }); + }); + + describe('recurring rules operations', () => { + it('should fetch recurring rules', async () => { + const mockRules = [{ id: 1, name: 'Rule 1' }]; + api.listRecurringRules.mockResolvedValue(mockRules); + + const { result } = renderHook(() => useChannelsStore()); + + await act(async () => { + await result.current.fetchRecurringRules(); + }); + + expect(result.current.recurringRules).toEqual(mockRules); + }); + + it('should remove a recurring rule', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + recurringRules: [{ id: 1 }, { id: 2 }], + }); + }); + + act(() => { + result.current.removeRecurringRule(1); + }); + + expect(result.current.recurringRules).toHaveLength(1); + }); + }); + + describe('helper methods', () => { + it('should validate if channel group can be edited', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { + 1: { id: 1, canEdit: true }, + 2: { id: 2, canEdit: false }, + }, + }); + }); + + expect(result.current.canEditChannelGroup(1)).toBe(true); + expect(result.current.canEditChannelGroup(2)).toBe(false); + }); + + it('should validate if channel group can be deleted', () => { + const { result } = renderHook(() => useChannelsStore()); + + act(() => { + useChannelsStore.setState({ + channelGroups: { + 1: { id: 1, canDelete: true }, + 2: { id: 2, canDelete: false }, + }, + }); + }); + + expect(result.current.canDeleteChannelGroup(1)).toBe(true); + expect(result.current.canDeleteChannelGroup(2)).toBe(false); + }); + }); + + describe('triggerUpdate', () => { + it('should update forceUpdate timestamp', () => { + const { result } = renderHook(() => useChannelsStore()); + const initialUpdate = result.current.forceUpdate; + + act(() => { + result.current.triggerUpdate(); + }); + + expect(result.current.forceUpdate).not.toBe(initialUpdate); + }); + }); +}); diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx new file mode 100644 index 00000000..171f0672 --- /dev/null +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -0,0 +1,295 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useChannelsTableStore from '../channelsTable'; + +describe('useChannelsTableStore', () => { + beforeEach(() => { + // Mock localStorage + const mockLocalStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + clear: vi.fn(), + }; + global.localStorage = mockLocalStorage; + + vi.clearAllMocks(); + + // Reset store state between tests + useChannelsTableStore.setState({ + channels: [], + pageCount: 0, + totalCount: 0, + sorting: [{ id: 'channel_number', desc: false }], + pagination: { + pageIndex: 0, + pageSize: 50, + }, + selectedChannelIds: [], + allQueryIds: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + expect(result.current.channels).toEqual([]); + expect(result.current.pageCount).toBe(0); + expect(result.current.totalCount).toBe(0); + expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]); + expect(result.current.pagination.pageIndex).toBe(0); + expect(result.current.pagination.pageSize).toBe(50); + expect(result.current.selectedChannelIds).toEqual([]); + expect(result.current.allQueryIds).toEqual([]); + }); + }); + + describe('queryChannels', () => { + it('should update channels, totalCount, and pageCount', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockResults = [ + { id: 1, name: 'Channel 1' }, + { id: 2, name: 'Channel 2' }, + ]; + const mockData = { + results: mockResults, + count: 150, + }; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryChannels(mockData, mockParams); + }); + + expect(result.current.channels).toEqual(mockResults); + expect(result.current.totalCount).toBe(150); + expect(result.current.pageCount).toBe(3); // Math.ceil(150 / 50) + }); + + it('should calculate pageCount correctly with different page sizes', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockData = { + results: [], + count: 75, + }; + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryChannels(mockData, mockParams); + }); + + expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25) + }); + + it('should handle zero results', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockData = { + results: [], + count: 0, + }; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryChannels(mockData, mockParams); + }); + + expect(result.current.channels).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageCount).toBe(0); + }); + }); + + describe('setAllQueryIds', () => { + it('should update allQueryIds', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockIds = [1, 2, 3, 4, 5]; + + act(() => { + result.current.setAllQueryIds(mockIds); + }); + + expect(result.current.allQueryIds).toEqual(mockIds); + }); + + it('should replace existing allQueryIds', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setAllQueryIds([1, 2, 3]); + }); + + expect(result.current.allQueryIds).toEqual([1, 2, 3]); + + act(() => { + result.current.setAllQueryIds([4, 5, 6]); + }); + + expect(result.current.allQueryIds).toEqual([4, 5, 6]); + }); + }); + + describe('setSelectedChannelIds', () => { + it('should update selectedChannelIds', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockIds = [1, 3, 5]; + + act(() => { + result.current.setSelectedChannelIds(mockIds); + }); + + expect(result.current.selectedChannelIds).toEqual(mockIds); + }); + + it('should clear selectedChannelIds when empty array is passed', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setSelectedChannelIds([1, 2, 3]); + }); + + expect(result.current.selectedChannelIds).toEqual([1, 2, 3]); + + act(() => { + result.current.setSelectedChannelIds([]); + }); + + expect(result.current.selectedChannelIds).toEqual([]); + }); + }); + + describe('getChannelStreams', () => { + it('should return streams for existing channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', streams: ['stream1', 'stream2'] }, + { id: 2, name: 'Channel 2', streams: ['stream3'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const streams = result.current.getChannelStreams(1); + + expect(streams).toEqual(['stream1', 'stream2']); + }); + + it('should return empty array for channel without streams', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1' }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const streams = result.current.getChannelStreams(1); + + expect(streams).toEqual([]); + }); + + it('should return empty array for non-existent channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', streams: ['stream1'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const streams = result.current.getChannelStreams(999); + + expect(streams).toEqual([]); + }); + }); + + describe('setPagination', () => { + it('should update pagination', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const newPagination = { + pageIndex: 2, + pageSize: 100, + }; + + act(() => { + result.current.setPagination(newPagination); + }); + + expect(result.current.pagination).toEqual(newPagination); + }); + + it('should update only changed pagination properties', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setPagination({ + pageIndex: 5, + pageSize: 50, + }); + }); + + expect(result.current.pagination.pageIndex).toBe(5); + expect(result.current.pagination.pageSize).toBe(50); + }); + }); + + describe('setSorting', () => { + it('should update sorting', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const newSorting = [{ id: 'name', desc: true }]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle multiple sorting columns', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const newSorting = [ + { id: 'name', desc: false }, + { id: 'channel_number', desc: true }, + ]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should clear sorting when empty array is passed', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setSorting([{ id: 'name', desc: true }]); + }); + + expect(result.current.sorting).toHaveLength(1); + + act(() => { + result.current.setSorting([]); + }); + + expect(result.current.sorting).toEqual([]); + }); + }); +}); diff --git a/frontend/src/store/__tests__/epgs.test.jsx b/frontend/src/store/__tests__/epgs.test.jsx new file mode 100644 index 00000000..05f136d3 --- /dev/null +++ b/frontend/src/store/__tests__/epgs.test.jsx @@ -0,0 +1,687 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import useEPGsStore from '../epgs'; +import api from '../../api'; + +// Mock the api module +vi.mock('../../api'); + +describe('useEPGsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset store state between tests + useEPGsStore.setState({ + epgs: {}, + tvgs: [], + tvgsById: {}, + tvgsLoaded: false, + isLoading: false, + error: null, + refreshProgress: {}, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useEPGsStore()); + + expect(result.current.epgs).toEqual({}); + expect(result.current.tvgs).toEqual([]); + expect(result.current.tvgsById).toEqual({}); + expect(result.current.tvgsLoaded).toBe(false); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.refreshProgress).toEqual({}); + }); + }); + + describe('fetchEPGs', () => { + it('should fetch and store EPGs successfully', async () => { + const mockEPGs = [ + { id: 'epg1', name: 'EPG 1', status: 'idle' }, + { id: 'epg2', name: 'EPG 2', status: 'success' }, + ]; + + api.getEPGs.mockResolvedValue(mockEPGs); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGs(); + }); + + expect(result.current.epgs).toEqual({ + epg1: { id: 'epg1', name: 'EPG 1', status: 'idle' }, + epg2: { id: 'epg2', name: 'EPG 2', status: 'success' }, + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(api.getEPGs).toHaveBeenCalledTimes(1); + }); + + it('should set loading state while fetching', async () => { + api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100))); + + const { result } = renderHook(() => useEPGsStore()); + + const fetchPromise = act(async () => { + await result.current.fetchEPGs(); + }); + + // Check loading state immediately after calling + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await fetchPromise; + + expect(result.current.isLoading).toBe(false); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Network error'); + api.getEPGs.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGs(); + }); + + expect(result.current.error).toBe('Failed to load epgs.'); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError); + + consoleSpy.mockRestore(); + }); + + it('should handle empty EPGs array', async () => { + api.getEPGs.mockResolvedValue([]); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGs(); + }); + + expect(result.current.epgs).toEqual({}); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('fetchEPGData', () => { + it('should fetch and store TVG data successfully', async () => { + const mockTVGs = [ + { id: 'tvg1', name: 'TVG 1' }, + { id: 'tvg2', name: 'TVG 2' }, + ]; + + api.getEPGData.mockResolvedValue(mockTVGs); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGData(); + }); + + expect(result.current.tvgs).toEqual(mockTVGs); + expect(result.current.tvgsById).toEqual({ + tvg1: { id: 'tvg1', name: 'TVG 1' }, + tvg2: { id: 'tvg2', name: 'TVG 2' }, + }); + expect(result.current.tvgsLoaded).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(api.getEPGData).toHaveBeenCalledTimes(1); + }); + + it('should set loading state while fetching', async () => { + api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100))); + + const { result } = renderHook(() => useEPGsStore()); + + const fetchPromise = act(async () => { + await result.current.fetchEPGData(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + + await fetchPromise; + + expect(result.current.isLoading).toBe(false); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('API error'); + api.getEPGData.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGData(); + }); + + expect(result.current.error).toBe('Failed to load tvgs.'); + expect(result.current.tvgsLoaded).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError); + + consoleSpy.mockRestore(); + }); + + it('should handle empty TVG array', async () => { + api.getEPGData.mockResolvedValue([]); + + const { result } = renderHook(() => useEPGsStore()); + + await act(async () => { + await result.current.fetchEPGData(); + }); + + expect(result.current.tvgs).toEqual([]); + expect(result.current.tvgsById).toEqual({}); + expect(result.current.tvgsLoaded).toBe(true); + }); + }); + + describe('addEPG', () => { + it('should add new EPG to store', () => { + const { result } = renderHook(() => useEPGsStore()); + + const newEPG = { id: 'epg1', name: 'New EPG', status: 'idle' }; + + act(() => { + result.current.addEPG(newEPG); + }); + + expect(result.current.epgs).toEqual({ + epg1: newEPG, + }); + }); + + it('should add multiple EPGs', () => { + const { result } = renderHook(() => useEPGsStore()); + + const epg1 = { id: 'epg1', name: 'EPG 1', status: 'idle' }; + const epg2 = { id: 'epg2', name: 'EPG 2', status: 'success' }; + + act(() => { + result.current.addEPG(epg1); + result.current.addEPG(epg2); + }); + + expect(result.current.epgs).toEqual({ + epg1, + epg2, + }); + }); + + it('should not overwrite existing EPGs', () => { + const { result } = renderHook(() => useEPGsStore()); + + const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' }; + const newEPG = { id: 'epg2', name: 'New', status: 'success' }; + + act(() => { + result.current.addEPG(originalEPG); + result.current.addEPG(newEPG); + }); + + expect(result.current.epgs).toEqual({ + epg1: originalEPG, + epg2: newEPG, + }); + }); + }); + + describe('updateEPG', () => { + it('should update existing EPG', () => { + const { result } = renderHook(() => useEPGsStore()); + + const originalEPG = { id: 'epg1', name: 'Original', status: 'idle' }; + const updatedEPG = { id: 'epg1', name: 'Updated', status: 'success' }; + + act(() => { + result.current.addEPG(originalEPG); + }); + + act(() => { + result.current.updateEPG(updatedEPG); + }); + + expect(result.current.epgs.epg1).toEqual(updatedEPG); + }); + + it('should add EPG if it does not exist', () => { + const { result } = renderHook(() => useEPGsStore()); + + const newEPG = { id: 'epg1', name: 'New', status: 'idle' }; + + act(() => { + result.current.updateEPG(newEPG); + }); + + expect(result.current.epgs.epg1).toEqual(newEPG); + }); + + it('should not update state when called with invalid epg (null)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.updateEPG(null); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null); + + consoleSpy.mockRestore(); + }); + + it('should not update state when called with invalid epg (missing id)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + const invalidEPG = { name: 'No ID' }; + + act(() => { + result.current.updateEPG(invalidEPG); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG); + + consoleSpy.mockRestore(); + }); + + it('should not update state when called with invalid epg (non-object)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.updateEPG('invalid'); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid'); + + consoleSpy.mockRestore(); + }); + }); + + describe('removeEPGs', () => { + it('should remove single EPG', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + epg1: { id: 'epg1', name: 'EPG 1' }, + epg2: { id: 'epg2', name: 'EPG 2' }, + }, + }); + }); + + act(() => { + result.current.removeEPGs(['epg1']); + }); + + expect(result.current.epgs).toEqual({ + epg2: { id: 'epg2', name: 'EPG 2' }, + }); + }); + + it('should remove multiple EPGs', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + epg1: { id: 'epg1', name: 'EPG 1' }, + epg2: { id: 'epg2', name: 'EPG 2' }, + epg3: { id: 'epg3', name: 'EPG 3' }, + }, + }); + }); + + act(() => { + result.current.removeEPGs(['epg1', 'epg3']); + }); + + expect(result.current.epgs).toEqual({ + epg2: { id: 'epg2', name: 'EPG 2' }, + }); + }); + + it('should handle removing non-existent EPG', () => { + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { + epg1: { id: 'epg1', name: 'EPG 1' }, + }; + + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.removeEPGs(['nonexistent']); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + }); + + it('should handle empty array', () => { + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { + epg1: { id: 'epg1', name: 'EPG 1' }, + }; + + act(() => { + useEPGsStore.setState({ epgs: initialEPGs }); + }); + + act(() => { + result.current.removeEPGs([]); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + }); + }); + + describe('updateEPGProgress', () => { + beforeEach(() => { + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'idle', last_message: '' }, + }, + }); + }); + }); + + it('should update progress for downloading action', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'downloading', + progress: 50, + speed: '1.5 MB/s', + elapsed_time: '00:00:30', + time_remaining: '00:00:30', + }); + }); + + expect(result.current.refreshProgress.source1).toEqual({ + action: 'downloading', + progress: 50, + speed: '1.5 MB/s', + elapsed_time: '00:00:30', + time_remaining: '00:00:30', + status: 'in_progress', + }); + expect(result.current.epgs.source1.status).toBe('fetching'); + }); + + it('should update progress for parsing_channels action', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'parsing_channels', + progress: 75, + }); + }); + + expect(result.current.epgs.source1.status).toBe('parsing'); + }); + + it('should update progress for parsing_programs action', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'parsing_programs', + progress: 90, + }); + }); + + expect(result.current.epgs.source1.status).toBe('parsing'); + }); + + it('should set status to success when progress is 100', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'success', + progress: 100, + }); + }); + + expect(result.current.epgs.source1.status).toBe('success'); + }); + + it('should use explicit status from data', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + status: 'error', + progress: 50, + }); + }); + + expect(result.current.epgs.source1.status).toBe('error'); + expect(result.current.refreshProgress.source1.status).toBe('error'); + }); + + it('should set last_message on error status', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + status: 'error', + error: 'Connection failed', + }); + }); + + expect(result.current.epgs.source1.last_message).toBe('Connection failed'); + }); + + it('should use default error message if error is not provided', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + status: 'error', + }); + }); + + expect(result.current.epgs.source1.last_message).toBe('Unknown error'); + }); + + it('should not update state when called with invalid data (null)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { ...result.current.epgs }; + const initialProgress = { ...result.current.refreshProgress }; + + act(() => { + result.current.updateEPGProgress(null); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(result.current.refreshProgress).toEqual(initialProgress); + expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null); + + consoleSpy.mockRestore(); + }); + + it('should not update state when called with invalid data (missing source)', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { ...result.current.epgs }; + const initialProgress = { ...result.current.refreshProgress }; + + act(() => { + result.current.updateEPGProgress({ progress: 50 }); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(result.current.refreshProgress).toEqual(initialProgress); + expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 }); + + consoleSpy.mockRestore(); + }); + + it('should not update state when source does not exist and no status', () => { + const { result } = renderHook(() => useEPGsStore()); + + const initialEPGs = { ...result.current.epgs }; + const initialProgress = { ...result.current.refreshProgress }; + + act(() => { + result.current.updateEPGProgress({ + source: 'nonexistent', + progress: 50, + }); + }); + + expect(result.current.epgs).toEqual(initialEPGs); + expect(result.current.refreshProgress).toEqual(initialProgress); + }); + + it('should update refreshProgress even when source does not exist but status is provided', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + result.current.updateEPGProgress({ + source: 'newSource', + status: 'success', + progress: 100, + }); + }); + + expect(result.current.refreshProgress.newSource).toEqual({ + action: undefined, + progress: 100, + speed: undefined, + elapsed_time: undefined, + time_remaining: undefined, + status: 'success', + }); + }); + + it('should not update EPG if status and last_message have not changed', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'fetching', last_message: '' }, + }, + }); + }); + + const epgsBeforeUpdate = result.current.epgs; + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'downloading', + progress: 25, + }); + }); + + // EPGs object should be the same reference (not updated) + expect(result.current.epgs).toBe(epgsBeforeUpdate); + // But refreshProgress should be updated + expect(result.current.refreshProgress.source1.progress).toBe(25); + }); + + it('should update EPG if status changed', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'idle', last_message: '' }, + }, + }); + }); + + const epgsBeforeUpdate = result.current.epgs; + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'downloading', + progress: 25, + }); + }); + + // EPGs object should be different (updated) because status changed from 'idle' to 'fetching' + expect(result.current.epgs).not.toBe(epgsBeforeUpdate); + expect(result.current.epgs.source1.status).toBe('fetching'); + }); + + it('should preserve current EPG status when no status change is detected', () => { + const { result } = renderHook(() => useEPGsStore()); + + act(() => { + useEPGsStore.setState({ + epgs: { + source1: { id: 'source1', status: 'parsing', last_message: 'Processing' }, + }, + }); + }); + + act(() => { + result.current.updateEPGProgress({ + source: 'source1', + action: 'parsing_programs', + progress: 85, + }); + }); + + expect(result.current.epgs.source1.status).toBe('parsing'); + expect(result.current.epgs.source1.last_message).toBe('Processing'); + }); + }); +}); diff --git a/frontend/src/store/__tests__/logos.test.jsx b/frontend/src/store/__tests__/logos.test.jsx new file mode 100644 index 00000000..59ac4283 --- /dev/null +++ b/frontend/src/store/__tests__/logos.test.jsx @@ -0,0 +1,916 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import useLogosStore from '../logos'; +import api from '../../api'; + +// Mock the api module +vi.mock('../../api'); + +describe('useLogosStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Reset store state between tests + useLogosStore.setState({ + logos: {}, + channelLogos: {}, + isLoading: false, + backgroundLoading: false, + hasLoadedAll: false, + hasLoadedChannelLogos: false, + error: null, + allowLogoRendering: false, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.logos).toEqual({}); + expect(result.current.channelLogos).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.backgroundLoading).toBe(false); + expect(result.current.hasLoadedAll).toBe(false); + expect(result.current.hasLoadedChannelLogos).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.allowLogoRendering).toBe(false); + }); + }); + + describe('enableLogoRendering', () => { + it('should enable logo rendering', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + result.current.enableLogoRendering(); + }); + + expect(result.current.allowLogoRendering).toBe(true); + }); + }); + + describe('addLogo', () => { + it('should add logo to main logos store', () => { + const { result } = renderHook(() => useLogosStore()); + + const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + + act(() => { + result.current.addLogo(newLogo); + }); + + expect(result.current.logos).toEqual({ + logo1: newLogo, + }); + }); + + it('should add logo to channelLogos if hasLoadedChannelLogos is true', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedChannelLogos: true }); + }); + + const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + + act(() => { + result.current.addLogo(newLogo); + }); + + expect(result.current.logos).toEqual({ logo1: newLogo }); + expect(result.current.channelLogos).toEqual({ logo1: newLogo }); + }); + + it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => { + const { result } = renderHook(() => useLogosStore()); + + const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + + act(() => { + result.current.addLogo(newLogo); + }); + + expect(result.current.logos).toEqual({ logo1: newLogo }); + expect(result.current.channelLogos).toEqual({}); + }); + }); + + describe('updateLogo', () => { + it('should update logo in main logos store', () => { + const { result } = renderHook(() => useLogosStore()); + + const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; + const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + + act(() => { + useLogosStore.setState({ logos: { logo1: originalLogo } }); + }); + + act(() => { + result.current.updateLogo(updatedLogo); + }); + + expect(result.current.logos.logo1).toEqual(updatedLogo); + }); + + it('should update logo in channelLogos if it exists there', () => { + const { result } = renderHook(() => useLogosStore()); + + const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; + const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + + act(() => { + useLogosStore.setState({ + logos: { logo1: originalLogo }, + channelLogos: { logo1: originalLogo }, + }); + }); + + act(() => { + result.current.updateLogo(updatedLogo); + }); + + expect(result.current.logos.logo1).toEqual(updatedLogo); + expect(result.current.channelLogos.logo1).toEqual(updatedLogo); + }); + + it('should not update channelLogos if logo does not exist there', () => { + const { result } = renderHook(() => useLogosStore()); + + const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; + const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + + act(() => { + useLogosStore.setState({ logos: { logo1: originalLogo } }); + }); + + act(() => { + result.current.updateLogo(updatedLogo); + }); + + expect(result.current.channelLogos).toEqual({}); + }); + }); + + describe('removeLogo', () => { + it('should remove logo from both stores', () => { + const { result } = renderHook(() => useLogosStore()); + + const logo1 = { id: 'logo1', name: 'Logo 1' }; + const logo2 = { id: 'logo2', name: 'Logo 2' }; + + act(() => { + useLogosStore.setState({ + logos: { logo1, logo2 }, + channelLogos: { logo1, logo2 }, + }); + }); + + act(() => { + result.current.removeLogo('logo1'); + }); + + expect(result.current.logos).toEqual({ logo2 }); + expect(result.current.channelLogos).toEqual({ logo2 }); + }); + + it('should handle removing non-existent logo', () => { + const { result } = renderHook(() => useLogosStore()); + + const logo1 = { id: 'logo1', name: 'Logo 1' }; + + act(() => { + useLogosStore.setState({ logos: { logo1 } }); + }); + + act(() => { + result.current.removeLogo('nonexistent'); + }); + + expect(result.current.logos).toEqual({ logo1 }); + }); + }); + + describe('fetchLogos', () => { + it('should fetch logos successfully with array response', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + let response; + await act(async () => { + response = await result.current.fetchLogos(100); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.isLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledWith({ page_size: 100 }); + expect(response).toEqual(mockLogos); + }); + + it('should fetch logos successfully with paginated response', async () => { + const mockResponse = { + results: [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ], + count: 2, + }; + + api.getLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchLogos(50); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(api.getLogos).toHaveBeenCalledWith({ page_size: 50 }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Network error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchLogos(); + }) + ).rejects.toThrow('Network error'); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to load logos.'); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchAllLogos', () => { + it('should fetch all logos successfully', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + let response; + await act(async () => { + response = await result.current.fetchAllLogos(); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.hasLoadedAll).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' }); + expect(response).toEqual(mockLogos); + }); + + it('should not refetch if already loaded and not forced', async () => { + const mockLogos = [{ id: 'logo1', name: 'Logo 1' }]; + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + logos: { logo1: mockLogos[0] }, + hasLoadedAll: true, + }); + }); + + const response = await act(async () => { + return await result.current.fetchAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + expect(response).toEqual([mockLogos[0]]); + }); + + it('should refetch if forced', async () => { + const mockLogos = [{ id: 'logo1', name: 'Logo 1' }]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + logos: { logo1: mockLogos[0] }, + hasLoadedAll: true, + }); + }); + + await act(async () => { + await result.current.fetchAllLogos(true); + }); + + expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' }); + }); + + it('should not refetch if already loading', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ isLoading: true }); + }); + + const response = await act(async () => { + return await result.current.fetchAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + expect(response).toEqual([]); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('API error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchAllLogos(); + }) + ).rejects.toThrow('API error'); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to load all logos.'); + expect(result.current.isLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchUsedLogos', () => { + it('should fetch used logos successfully', async () => { + const mockResponse = { + results: [ + { id: 'logo1', name: 'Used Logo 1' }, + { id: 'logo2', name: 'Used Logo 2' }, + ], + }; + + api.getLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useLogosStore()); + + let response; + await act(async () => { + response = await result.current.fetchUsedLogos(100); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Used Logo 1' }, + logo2: { id: 'logo2', name: 'Used Logo 2' }, + }); + expect(result.current.isLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 }); + expect(response).toEqual(mockResponse); + }); + + it('should merge with existing logos', async () => { + const existingLogo = { id: 'logo1', name: 'Existing Logo' }; + const newLogo = { id: 'logo2', name: 'New Logo' }; + + api.getLogos.mockResolvedValue({ results: [newLogo] }); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: existingLogo } }); + }); + + await act(async () => { + await result.current.fetchUsedLogos(); + }); + + expect(result.current.logos).toEqual({ + logo1: existingLogo, + logo2: newLogo, + }); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Fetch error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchUsedLogos(); + }) + ).rejects.toThrow('Fetch error'); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to load used logos.'); + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError); + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchChannelAssignableLogos', () => { + it('should return cached logos if already loaded', async () => { + const cachedLogos = { + logo1: { id: 'logo1', name: 'Cached Logo' }, + }; + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + channelLogos: cachedLogos, + hasLoadedChannelLogos: true, + }); + }); + + const response = await act(async () => { + return await result.current.fetchChannelAssignableLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + expect(response).toEqual([cachedLogos.logo1]); + }); + + it('should fetch and cache logos if not loaded', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchChannelAssignableLogos(); + }); + + expect(result.current.channelLogos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.hasLoadedChannelLogos).toBe(true); + expect(api.getLogos).toHaveBeenCalledWith({ no_pagination: 'true' }); + }); + }); + + describe('fetchLogosByIds', () => { + it('should fetch missing logos by IDs', async () => { + const existingLogo = { id: 'logo1', name: 'Existing' }; + const newLogo = { id: 'logo2', name: 'New' }; + + api.getLogosByIds.mockResolvedValue([newLogo]); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: existingLogo } }); + }); + + let response; + await act(async () => { + response = await result.current.fetchLogosByIds(['logo1', 'logo2']); + }); + + expect(api.getLogosByIds).toHaveBeenCalledWith(['logo2']); + expect(result.current.logos).toEqual({ + logo1: existingLogo, + logo2: newLogo, + }); + expect(response).toEqual([newLogo]); + }); + + it('should return empty array if all logos exist', async () => { + const logo1 = { id: 'logo1', name: 'Logo 1' }; + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1 } }); + }); + + const response = await act(async () => { + return await result.current.fetchLogosByIds(['logo1']); + }); + + expect(api.getLogosByIds).not.toHaveBeenCalled(); + expect(response).toEqual([]); + }); + + it('should handle fetch error', async () => { + const mockError = new Error('Fetch error'); + api.getLogosByIds.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await expect( + act(async () => { + await result.current.fetchLogosByIds(['logo1']); + }) + ).rejects.toThrow('Fetch error'); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError); + + consoleSpy.mockRestore(); + }); + }); + + describe('fetchLogosInBackground', () => { + it('should fetch logos in background with pagination', async () => { + // vi.useRealTimers(); + + const page1 = { + results: [{ id: 'logo1', name: 'Logo 1' }], + next: 'http://example.com/page2', + }; + const page2 = { + results: [{ id: 'logo2', name: 'Logo 2' }], + next: null, + }; + + api.getLogos + .mockResolvedValueOnce(page1) + .mockResolvedValueOnce(page2); + + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchLogosInBackground(); + }); + + expect(result.current.logos).toEqual({ + logo1: { id: 'logo1', name: 'Logo 1' }, + logo2: { id: 'logo2', name: 'Logo 2' }, + }); + expect(result.current.backgroundLoading).toBe(false); + expect(api.getLogos).toHaveBeenCalledTimes(2); + expect(api.getLogos).toHaveBeenCalledWith({ page: 1, page_size: 200 }); + expect(api.getLogos).toHaveBeenCalledWith({ page: 2, page_size: 200 }); + }); + + it('should handle errors gracefully without throwing', async () => { + const mockError = new Error('Network error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.fetchLogosInBackground(); + }); + + expect(result.current.backgroundLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError); + + consoleSpy.mockRestore(); + }); + }); + + describe('backgroundLoadAllLogos', () => { + it('should not start if already loading', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ backgroundLoading: true }); + }); + + await act(async () => { + await result.current.backgroundLoadAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should not start if already loaded', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedAll: true }); + }); + + await act(async () => { + await result.current.backgroundLoadAllLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should load logos in background asynchronously', async () => { + vi.useFakeTimers(); + + const mockLogos = Array.from({ length: 2500 }, (_, i) => ({ + id: `logo${i}`, + name: `Logo ${i}`, + })); + + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + // Start background loading + result.current.backgroundLoadAllLogos(); + + // Advance timers to execute setTimeout + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.hasLoadedAll).toBe(true); + expect(result.current.backgroundLoading).toBe(false); + expect(Object.keys(result.current.logos).length).toBe(2500); + + vi.useRealTimers(); + }); + + it('should handle errors gracefully', async () => { + vi.useFakeTimers(); + + const mockError = new Error('Fetch error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + result.current.backgroundLoadAllLogos(); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(result.current.backgroundLoading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError); + + consoleSpy.mockRestore(); + + vi.useRealTimers(); + }); + }); + + describe('backgroundLoadChannelLogos', () => { + it('should not start if already loading', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ backgroundLoading: true }); + }); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should not start if already loaded', async () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedChannelLogos: true }); + }); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should not start if channelLogos already has many items', async () => { + const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]); + const channelLogosObj = Object.fromEntries(channelLogos); + + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ channelLogos: channelLogosObj }); + }); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(api.getLogos).not.toHaveBeenCalled(); + }); + + it('should load channel logos in background', async () => { + const mockLogos = [ + { id: 'logo1', name: 'Logo 1' }, + { id: 'logo2', name: 'Logo 2' }, + ]; + + api.getLogos.mockResolvedValue(mockLogos); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(result.current.hasLoadedChannelLogos).toBe(true); + expect(result.current.backgroundLoading).toBe(false); + expect(Object.keys(result.current.channelLogos).length).toBe(2); + expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...'); + expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos'); + + consoleSpy.mockRestore(); + }); + + it('should handle errors gracefully', async () => { + const mockError = new Error('Fetch error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + await act(async () => { + await result.current.backgroundLoadChannelLogos(); + }); + + expect(result.current.backgroundLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError); + + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + }); + + describe('startBackgroundLoading', () => { + it('should start background loading after delay', async () => { + vi.useFakeTimers(); + + const mockLogos = [{ id: 'logo1', name: 'Logo 1' }]; + api.getLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useLogosStore()); + + result.current.startBackgroundLoading(); + + // Advance timer by 3 seconds + await act(async () => { + vi.advanceTimersByTime(3000); + await vi.runAllTimersAsync(); + }); + + expect(result.current.hasLoadedAll).toBe(true); + + vi.useRealTimers(); + }); + + it('should handle errors in background loading', async () => { + vi.useFakeTimers(); + + const mockError = new Error('Background error'); + api.getLogos.mockRejectedValue(mockError); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { result } = renderHook(() => useLogosStore()); + + result.current.startBackgroundLoading(); + + await act(async () => { + vi.advanceTimersByTime(3000); + await vi.runAllTimersAsync(); + }); + + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + + vi.useRealTimers(); + }); + }); + + describe('helper methods', () => { + describe('getLogoById', () => { + it('should return logo if it exists', () => { + const logo = { id: 'logo1', name: 'Logo 1' }; + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: logo } }); + }); + + expect(result.current.getLogoById('logo1')).toEqual(logo); + }); + + it('should return null if logo does not exist', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.getLogoById('nonexistent')).toBeNull(); + }); + }); + + describe('hasLogo', () => { + it('should return true if logo exists', () => { + const logo = { id: 'logo1', name: 'Logo 1' }; + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ logos: { logo1: logo } }); + }); + + expect(result.current.hasLogo('logo1')).toBe(true); + }); + + it('should return false if logo does not exist', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.hasLogo('nonexistent')).toBe(false); + }); + }); + + describe('getLogosCount', () => { + it('should return correct count of logos', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + logos: { + logo1: { id: 'logo1' }, + logo2: { id: 'logo2' }, + logo3: { id: 'logo3' }, + }, + }); + }); + + expect(result.current.getLogosCount()).toBe(3); + }); + + it('should return 0 for empty logos', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.getLogosCount()).toBe(0); + }); + }); + + describe('needsAllLogos', () => { + it('should return true if hasLoadedAll is false', () => { + const { result } = renderHook(() => useLogosStore()); + + expect(result.current.needsAllLogos()).toBe(true); + }); + + it('should return true if logos is empty', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ hasLoadedAll: true, logos: {} }); + }); + + expect(result.current.needsAllLogos()).toBe(true); + }); + + it('should return false if hasLoadedAll is true and logos exist', () => { + const { result } = renderHook(() => useLogosStore()); + + act(() => { + useLogosStore.setState({ + hasLoadedAll: true, + logos: { logo1: { id: 'logo1' } }, + }); + }); + + expect(result.current.needsAllLogos()).toBe(false); + }); + }); + }); +}); diff --git a/frontend/src/store/__tests__/playlists.test.jsx b/frontend/src/store/__tests__/playlists.test.jsx new file mode 100644 index 00000000..2d49791e --- /dev/null +++ b/frontend/src/store/__tests__/playlists.test.jsx @@ -0,0 +1,266 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import usePlaylistsStore from '../playlists'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('usePlaylistsStore', () => { + beforeEach(() => { + const { result } = renderHook(() => usePlaylistsStore()); + act(() => { + result.current.playlists = []; + result.current.profiles = {}; + result.current.refreshProgress = {}; + result.current.isLoading = false; + result.current.error = null; + result.current.profileSearchPreview = ''; + result.current.profileResult = ''; + result.current.editPlaylistId = null; + }); + vi.clearAllMocks(); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + expect(result.current.playlists).toEqual([]); + expect(result.current.profiles).toEqual({}); + expect(result.current.refreshProgress).toEqual({}); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.profileSearchPreview).toBe(''); + expect(result.current.profileResult).toBe(''); + expect(result.current.editPlaylistId).toBe(null); + }); + + it('should set edit playlist id', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.setEditPlaylistId('playlist1'); + }); + + expect(result.current.editPlaylistId).toBe('playlist1'); + }); + + it('should fetch playlist successfully', async () => { + const mockPlaylist = { + id: 'playlist1', + name: 'Test Playlist', + profiles: ['profile1', 'profile2'], + }; + + api.getPlaylist.mockResolvedValue(mockPlaylist); + + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.playlists = [{ id: 'playlist1', name: 'Old Name' }]; + }); + + await act(async () => { + await result.current.fetchPlaylist('playlist1'); + }); + + expect(api.getPlaylist).toHaveBeenCalledWith('playlist1'); + expect(result.current.playlists).toEqual([mockPlaylist]); + expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch playlist error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + api.getPlaylist.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => usePlaylistsStore()); + + await act(async () => { + await result.current.fetchPlaylist('playlist1'); + }); + + expect(result.current.error).toBe('Failed to load playlists.'); + expect(result.current.isLoading).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should fetch playlists successfully', async () => { + const mockPlaylists = [ + { id: 'playlist1', name: 'Playlist 1', profiles: ['profile1'] }, + { id: 'playlist2', name: 'Playlist 2', profiles: ['profile2'] }, + ]; + + api.getPlaylists.mockResolvedValue(mockPlaylists); + + const { result } = renderHook(() => usePlaylistsStore()); + + await act(async () => { + await result.current.fetchPlaylists(); + }); + + expect(api.getPlaylists).toHaveBeenCalled(); + expect(result.current.playlists).toEqual(mockPlaylists); + expect(result.current.profiles).toEqual({ + playlist1: ['profile1'], + playlist2: ['profile2'], + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch playlists error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + api.getPlaylists.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => usePlaylistsStore()); + + await act(async () => { + await result.current.fetchPlaylists(); + }); + + expect(result.current.error).toBe('Failed to load playlists.'); + expect(result.current.isLoading).toBe(false); + consoleErrorSpy.mockRestore(); + }); + + it('should add playlist', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const newPlaylist = { + id: 'playlist1', + name: 'New Playlist', + profiles: ['profile1'], + }; + + act(() => { + result.current.addPlaylist(newPlaylist); + }); + + expect(result.current.playlists).toEqual([newPlaylist]); + expect(result.current.profiles).toEqual({ playlist1: ['profile1'] }); + }); + + it('should update playlist', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] }; + const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] }; + + act(() => { + result.current.playlists = [existingPlaylist]; + result.current.profiles = { playlist1: ['profile1'] }; + }); + + act(() => { + result.current.updatePlaylist(updatedPlaylist); + }); + + expect(result.current.playlists).toEqual([updatedPlaylist]); + expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] }); + }); + + it('should update profiles', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.profiles = { playlist1: ['profile1'] }; + }); + + act(() => { + result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']); + }); + + expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] }); + }); + + it('should remove playlists', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.playlists = [ + { id: 'playlist1', name: 'Playlist 1' }, + { id: 'playlist2', name: 'Playlist 2' }, + { id: 'playlist3', name: 'Playlist 3' }, + ]; + }); + + act(() => { + result.current.removePlaylists(['playlist1', 'playlist3']); + }); + + expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]); + }); + + it('should set refresh progress with two parameters', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const progressData = { action: 'refreshing', progress: 50 }; + + act(() => { + result.current.setRefreshProgress('account1', progressData); + }); + + expect(result.current.refreshProgress).toEqual({ account1: progressData }); + }); + + it('should set refresh progress with WebSocket data', () => { + const { result } = renderHook(() => usePlaylistsStore()); + const wsData = { account: 'account1', action: 'refreshing', progress: 50 }; + + act(() => { + result.current.setRefreshProgress(wsData); + }); + + expect(result.current.refreshProgress).toEqual({ account1: wsData }); + }); + + it('should preserve initializing status until real progress', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.refreshProgress = { + account1: { action: 'initializing', progress: 0 }, + }; + }); + + act(() => { + result.current.setRefreshProgress({ account: 'account1', progress: 0 }); + }); + + expect(result.current.refreshProgress.account1.action).toBe('initializing'); + + act(() => { + result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 }); + }); + + expect(result.current.refreshProgress.account1.action).toBe('refreshing'); + }); + + it('should remove refresh progress', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.refreshProgress = { + account1: { action: 'refreshing', progress: 50 }, + account2: { action: 'refreshing', progress: 75 }, + }; + }); + + act(() => { + result.current.removeRefreshProgress('account1'); + }); + + expect(result.current.refreshProgress).toEqual({ + account2: { action: 'refreshing', progress: 75 }, + }); + }); + + it('should set profile preview', () => { + const { result } = renderHook(() => usePlaylistsStore()); + + act(() => { + result.current.setProfilePreview('search text', 'result data'); + }); + + expect(result.current.profileSearchPreview).toBe('search text'); + expect(result.current.profileResult).toBe('result data'); + }); +}); diff --git a/frontend/src/store/__tests__/plugins.test.jsx b/frontend/src/store/__tests__/plugins.test.jsx new file mode 100644 index 00000000..2db48095 --- /dev/null +++ b/frontend/src/store/__tests__/plugins.test.jsx @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { usePluginStore } from '../plugins'; +import API from '../../api'; + +vi.mock('../../api'); + +describe('usePluginStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + + usePluginStore.setState({ + plugins: [], + loading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => usePluginStore()); + + expect(result.current.plugins).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch plugins successfully', async () => { + const mockPlugins = [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ]; + + API.getPlugins.mockResolvedValue(mockPlugins); + + const { result } = renderHook(() => usePluginStore()); + + await act(async () => { + await result.current.fetchPlugins(); + }); + + expect(API.getPlugins).toHaveBeenCalled(); + expect(result.current.plugins).toEqual(mockPlugins); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch plugins with empty response', async () => { + API.getPlugins.mockResolvedValue(null); + + const { result } = renderHook(() => usePluginStore()); + + await act(async () => { + await result.current.fetchPlugins(); + }); + + expect(result.current.plugins).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch plugins error', async () => { + const mockError = new Error('Network error'); + API.getPlugins.mockRejectedValue(mockError); + + const { result } = renderHook(() => usePluginStore()); + + await act(async () => { + await result.current.fetchPlugins(); + }); + + expect(result.current.error).toEqual(mockError); + expect(result.current.loading).toBe(false); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + API.getPlugins.mockReturnValue(promise); + + const { result } = renderHook(() => usePluginStore()); + + // Start the fetch without awaiting + act(() => { + result.current.fetchPlugins(); + }); + + // Check loading is true synchronously + expect(result.current.loading).toBe(true); + + // Resolve the promise and wait for state update + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.loading).toBe(false); + }); + + it('should update plugin', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: false }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ], + }); + }); + + act(() => { + result.current.updatePlugin('plugin1', { enabled: true }); + }); + + expect(result.current.plugins).toEqual([ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ]); + }); + + it('should not modify other plugins when updating', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: false }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ], + }); + }); + + act(() => { + result.current.updatePlugin('plugin1', { name: 'Updated Plugin' }); + }); + + expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false }); + }); + + it('should add plugin', () => { + const { result } = renderHook(() => usePluginStore()); + const newPlugin = { key: 'plugin1', name: 'New Plugin', enabled: true }; + + act(() => { + result.current.addPlugin(newPlugin); + }); + + expect(result.current.plugins).toEqual([newPlugin]); + }); + + it('should add plugin to existing plugins', () => { + const { result } = renderHook(() => usePluginStore()); + const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true }; + const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false }; + + act(() => { + usePluginStore.setState({ plugins: [existingPlugin] }); + }); + + act(() => { + result.current.addPlugin(newPlugin); + }); + + expect(result.current.plugins).toEqual([existingPlugin, newPlugin]); + }); + + it('should remove plugin', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ], + }); + }); + + act(() => { + result.current.removePlugin('plugin1'); + }); + + expect(result.current.plugins).toEqual([ + { key: 'plugin2', name: 'Plugin 2', enabled: false }, + ]); + }); + + it('should handle removing non-existent plugin', () => { + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + ], + }); + }); + + act(() => { + result.current.removePlugin('nonexistent'); + }); + + expect(result.current.plugins).toEqual([ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + ]); + }); + + it('should invalidate plugins and refetch', async () => { + const mockPlugins = [ + { key: 'plugin1', name: 'Plugin 1', enabled: true }, + ]; + + API.getPlugins.mockResolvedValue(mockPlugins); + + const { result } = renderHook(() => usePluginStore()); + + act(() => { + usePluginStore.setState({ + plugins: [ + { key: 'old-plugin', name: 'Old Plugin', enabled: false }, + ], + }); + }); + + await act(async () => { + await result.current.invalidatePlugins(); + }); + + expect(result.current.plugins).toEqual(mockPlugins); + expect(API.getPlugins).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx new file mode 100644 index 00000000..59da4437 --- /dev/null +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -0,0 +1,204 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useSettingsStore from '../settings'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useSettingsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSettingsStore.setState({ + settings: {}, + environment: { + public_ip: '', + country_code: '', + country_name: '', + env_mode: 'prod', + }, + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useSettingsStore()); + + expect(result.current.settings).toEqual({}); + expect(result.current.environment).toEqual({ + public_ip: '', + country_code: '', + country_name: '', + env_mode: 'prod', + }); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch settings successfully', async () => { + const mockSettings = [ + { key: 'setting1', value: 'value1' }, + { key: 'setting2', value: 'value2' }, + ]; + const mockEnv = { + public_ip: '192.168.1.1', + country_code: 'US', + country_name: 'United States', + env_mode: 'dev', + }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getSettings).toHaveBeenCalled(); + expect(api.getEnvironmentSettings).toHaveBeenCalled(); + expect(result.current.settings).toEqual({ + setting1: { key: 'setting1', value: 'value1' }, + setting2: { key: 'setting2', value: 'value2' }, + }); + expect(result.current.environment).toEqual(mockEnv); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle null environment response', async () => { + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(null); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(result.current.environment).toEqual({ + public_ip: '', + country_code: '', + country_name: '', + env_mode: 'prod', + }); + }); + + it('should handle fetch settings error', async () => { + const mockError = new Error('Network error'); + api.getSettings.mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(result.current.error).toBe('Failed to load settings.'); + expect(result.current.isLoading).toBe(false); + }); + + it('should set loading state during fetch', async () => { + let resolveSettingsPromise; + let resolveEnvPromise; + const settingsPromise = new Promise((resolve) => { + resolveSettingsPromise = resolve; + }); + const envPromise = new Promise((resolve) => { + resolveEnvPromise = resolve; + }); + + api.getSettings.mockReturnValue(settingsPromise); + api.getEnvironmentSettings.mockReturnValue(envPromise); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.fetchSettings(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolveSettingsPromise([]); + resolveEnvPromise({}); + await settingsPromise; + await envPromise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should update setting', () => { + useSettingsStore.setState({ + settings: { + setting1: { key: 'setting1', value: 'old_value' }, + setting2: { key: 'setting2', value: 'value2' }, + }, + }); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting({ key: 'setting1', value: 'new_value' }); + }); + + expect(result.current.settings).toEqual({ + setting1: { key: 'setting1', value: 'new_value' }, + setting2: { key: 'setting2', value: 'value2' }, + }); + }); + + it('should add new setting when updating non-existent key', () => { + useSettingsStore.setState({ + settings: { + setting1: { key: 'setting1', value: 'value1' }, + }, + }); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting({ key: 'setting2', value: 'new_value' }); + }); + + expect(result.current.settings).toEqual({ + setting1: { key: 'setting1', value: 'value1' }, + setting2: { key: 'setting2', value: 'new_value' }, + }); + }); + + it('should not modify other settings when updating', () => { + useSettingsStore.setState({ + settings: { + setting1: { key: 'setting1', value: 'value1' }, + setting2: { key: 'setting2', value: 'value2' }, + }, + }); + + const { result } = renderHook(() => useSettingsStore()); + + act(() => { + result.current.updateSetting({ key: 'setting1', value: 'updated' }); + }); + + expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' }); + }); + + it('should handle empty settings array', async () => { + api.getSettings.mockResolvedValue([]); + api.getEnvironmentSettings.mockResolvedValue({}); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(result.current.settings).toEqual({}); + }); +}); diff --git a/frontend/src/store/__tests__/streamProfiles.test.jsx b/frontend/src/store/__tests__/streamProfiles.test.jsx new file mode 100644 index 00000000..30380664 --- /dev/null +++ b/frontend/src/store/__tests__/streamProfiles.test.jsx @@ -0,0 +1,265 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useStreamProfilesStore from '../streamProfiles'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useStreamProfilesStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useStreamProfilesStore.setState({ + profiles: [], + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamProfilesStore()); + + expect(result.current.profiles).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch profiles successfully', async () => { + const mockProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]; + + api.getStreamProfiles.mockResolvedValue(mockProfiles); + + const { result } = renderHook(() => useStreamProfilesStore()); + + await act(async () => { + await result.current.fetchProfiles(); + }); + + expect(api.getStreamProfiles).toHaveBeenCalled(); + expect(result.current.profiles).toEqual(mockProfiles); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch profiles error', async () => { + const mockError = new Error('Network error'); + api.getStreamProfiles.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useStreamProfilesStore()); + + await act(async () => { + await result.current.fetchProfiles(); + }); + + expect(result.current.error).toBe('Failed to load profiles.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getStreamProfiles.mockReturnValue(promise); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.fetchProfiles(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add stream profile', () => { + useStreamProfilesStore.setState({ + profiles: [{ id: 1, name: 'Profile 1', bitrate: 5000 }], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const newProfile = { id: 2, name: 'Profile 2', bitrate: 8000 }; + + act(() => { + result.current.addStreamProfile(newProfile); + }); + + expect(result.current.profiles).toEqual([ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]); + }); + + it('should add stream profile to empty profiles', () => { + const { result } = renderHook(() => useStreamProfilesStore()); + const newProfile = { id: 1, name: 'Profile 1', bitrate: 5000 }; + + act(() => { + result.current.addStreamProfile(newProfile); + }); + + expect(result.current.profiles).toEqual([newProfile]); + }); + + it('should update stream profile', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 }; + + act(() => { + result.current.updateStreamProfile(updatedProfile); + }); + + expect(result.current.profiles).toEqual([ + { id: 1, name: 'Updated Profile', bitrate: 10000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]); + }); + + it('should not modify other profiles when updating', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const updatedProfile = { id: 1, name: 'Updated Profile', bitrate: 10000 }; + + act(() => { + result.current.updateStreamProfile(updatedProfile); + }); + + expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 }); + }); + + it('should not modify profiles when updating non-existent profile', () => { + const initialProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]; + + useStreamProfilesStore.setState({ + profiles: initialProfiles, + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 }; + + act(() => { + result.current.updateStreamProfile(nonExistentProfile); + }); + + expect(result.current.profiles).toEqual(initialProfiles); + }); + + it('should remove single stream profile', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + { id: 3, name: 'Profile 3', bitrate: 10000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([2]); + }); + + expect(result.current.profiles).toEqual([ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 3, name: 'Profile 3', bitrate: 10000 }, + ]); + }); + + it('should remove multiple stream profiles', () => { + useStreamProfilesStore.setState({ + profiles: [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + { id: 3, name: 'Profile 3', bitrate: 10000 }, + ], + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([1, 3]); + }); + + expect(result.current.profiles).toEqual([ + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]); + }); + + it('should handle removing non-existent profiles', () => { + const initialProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + { id: 2, name: 'Profile 2', bitrate: 8000 }, + ]; + + useStreamProfilesStore.setState({ + profiles: initialProfiles, + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([999]); + }); + + expect(result.current.profiles).toEqual(initialProfiles); + }); + + it('should handle removing from empty profiles', () => { + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([1, 2]); + }); + + expect(result.current.profiles).toEqual([]); + }); + + it('should handle empty array when removing profiles', () => { + const initialProfiles = [ + { id: 1, name: 'Profile 1', bitrate: 5000 }, + ]; + + useStreamProfilesStore.setState({ + profiles: initialProfiles, + }); + + const { result } = renderHook(() => useStreamProfilesStore()); + + act(() => { + result.current.removeStreamProfiles([]); + }); + + expect(result.current.profiles).toEqual(initialProfiles); + }); +}); diff --git a/frontend/src/store/__tests__/streams.test.jsx b/frontend/src/store/__tests__/streams.test.jsx new file mode 100644 index 00000000..e6ed3952 --- /dev/null +++ b/frontend/src/store/__tests__/streams.test.jsx @@ -0,0 +1,289 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useStreamsStore from '../streams'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useStreamsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useStreamsStore.setState({ + streams: [], + count: 0, + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamsStore()); + + expect(result.current.streams).toEqual([]); + expect(result.current.count).toBe(0); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch streams successfully', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ], + count: 2, + }; + + api.getStreams.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useStreamsStore()); + + await act(async () => { + await result.current.fetchStreams(); + }); + + expect(api.getStreams).toHaveBeenCalled(); + expect(result.current.streams).toEqual(mockResponse.results); + expect(result.current.count).toBe(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch streams error', async () => { + const mockError = new Error('Network error'); + api.getStreams.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useStreamsStore()); + + await act(async () => { + await result.current.fetchStreams(); + }); + + expect(result.current.error).toBe('Failed to load streams.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getStreams.mockReturnValue(promise); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.fetchStreams(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise({ results: [], count: 0 }); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add stream', () => { + useStreamsStore.setState({ + streams: [{ id: 1, name: 'Stream 1', url: 'http://example.com/1' }], + }); + + const { result } = renderHook(() => useStreamsStore()); + const newStream = { id: 2, name: 'Stream 2', url: 'http://example.com/2' }; + + act(() => { + result.current.addStream(newStream); + }); + + expect(result.current.streams).toEqual([ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]); + }); + + it('should add stream to empty streams', () => { + const { result } = renderHook(() => useStreamsStore()); + const newStream = { id: 1, name: 'Stream 1', url: 'http://example.com/1' }; + + act(() => { + result.current.addStream(newStream); + }); + + expect(result.current.streams).toEqual([newStream]); + }); + + it('should update stream', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }; + + act(() => { + result.current.updateStream(updatedStream); + }); + + expect(result.current.streams).toEqual([ + { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]); + }); + + it('should not modify other streams when updating', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }; + + act(() => { + result.current.updateStream(updatedStream); + }); + + expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' }); + }); + + it('should not modify streams when updating non-existent stream', () => { + const initialStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]; + + useStreamsStore.setState({ + streams: initialStreams, + }); + + const { result } = renderHook(() => useStreamsStore()); + const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' }; + + act(() => { + result.current.updateStream(nonExistentStream); + }); + + expect(result.current.streams).toEqual(initialStreams); + }); + + it('should remove single stream', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + { id: 3, name: 'Stream 3', url: 'http://example.com/3' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([2]); + }); + + expect(result.current.streams).toEqual([ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 3, name: 'Stream 3', url: 'http://example.com/3' }, + ]); + }); + + it('should remove multiple streams', () => { + useStreamsStore.setState({ + streams: [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + { id: 3, name: 'Stream 3', url: 'http://example.com/3' }, + ], + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([1, 3]); + }); + + expect(result.current.streams).toEqual([ + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]); + }); + + it('should handle removing non-existent streams', () => { + const initialStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + { id: 2, name: 'Stream 2', url: 'http://example.com/2' }, + ]; + + useStreamsStore.setState({ + streams: initialStreams, + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([999]); + }); + + expect(result.current.streams).toEqual(initialStreams); + }); + + it('should handle removing from empty streams', () => { + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([1, 2]); + }); + + expect(result.current.streams).toEqual([]); + }); + + it('should handle empty array when removing streams', () => { + const initialStreams = [ + { id: 1, name: 'Stream 1', url: 'http://example.com/1' }, + ]; + + useStreamsStore.setState({ + streams: initialStreams, + }); + + const { result } = renderHook(() => useStreamsStore()); + + act(() => { + result.current.removeStreams([]); + }); + + expect(result.current.streams).toEqual(initialStreams); + }); + + it('should handle fetch with empty results', async () => { + const mockResponse = { + results: [], + count: 0, + }; + + api.getStreams.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useStreamsStore()); + + await act(async () => { + await result.current.fetchStreams(); + }); + + expect(result.current.streams).toEqual([]); + expect(result.current.count).toBe(0); + }); +}); diff --git a/frontend/src/store/__tests__/useVODStore.test.jsx b/frontend/src/store/__tests__/useVODStore.test.jsx new file mode 100644 index 00000000..5734e5a2 --- /dev/null +++ b/frontend/src/store/__tests__/useVODStore.test.jsx @@ -0,0 +1,755 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useVODStore from '../useVODStore'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useVODStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useVODStore.setState({ + content: {}, + currentPageContent: [], + episodes: {}, + categories: {}, + loading: false, + error: null, + filters: { + type: 'all', + search: '', + category: '', + }, + currentPage: 1, + totalCount: 0, + pageSize: 24, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useVODStore()); + + expect(result.current.content).toEqual({}); + expect(result.current.currentPageContent).toEqual([]); + expect(result.current.episodes).toEqual({}); + expect(result.current.categories).toEqual({}); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.filters).toEqual({ + type: 'all', + search: '', + category: '', + }); + expect(result.current.currentPage).toBe(1); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageSize).toBe(24); + }); + + it('should set filters and reset to first page', () => { + useVODStore.setState({ currentPage: 5 }); + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ search: 'test', category: 'action' }); + }); + + expect(result.current.filters).toEqual({ + type: 'all', + search: 'test', + category: 'action', + }); + expect(result.current.currentPage).toBe(1); + }); + + it('should set page', () => { + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setPage(3); + }); + + expect(result.current.currentPage).toBe(3); + }); + + it('should set page size and reset to first page', () => { + useVODStore.setState({ currentPage: 3 }); + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setPageSize(50); + }); + + expect(result.current.pageSize).toBe(50); + expect(result.current.currentPage).toBe(1); + }); + + it('should fetch all content successfully', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Movie 1', content_type: 'movie' }, + { id: 2, name: 'Series 1', content_type: 'series' }, + ], + count: 2, + }; + + api.getAllContent.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getAllContent).toHaveBeenCalled(); + expect(result.current.currentPageContent).toEqual([ + { id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' }, + { id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' }, + ]); + expect(result.current.totalCount).toBe(2); + expect(result.current.loading).toBe(false); + }); + + it('should fetch only movies when filter type is movies', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Movie 1' }, + { id: 2, name: 'Movie 2' }, + ], + count: 2, + }; + + api.getMovies.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ type: 'movies' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getMovies).toHaveBeenCalled(); + expect(result.current.currentPageContent).toEqual([ + { id: 1, name: 'Movie 1', contentType: 'movie' }, + { id: 2, name: 'Movie 2', contentType: 'movie' }, + ]); + expect(result.current.totalCount).toBe(2); + }); + + it('should fetch only series when filter type is series', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Series 1' }, + { id: 2, name: 'Series 2' }, + ], + count: 2, + }; + + api.getSeries.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ type: 'series' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getSeries).toHaveBeenCalled(); + expect(result.current.currentPageContent).toEqual([ + { id: 1, name: 'Series 1', contentType: 'series' }, + { id: 2, name: 'Series 2', contentType: 'series' }, + ]); + expect(result.current.totalCount).toBe(2); + }); + + it('should handle fetch content error', async () => { + const mockError = new Error('Network error'); + api.getAllContent.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(result.current.error).toBe('Failed to load content.'); + expect(result.current.loading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should handle invalid response format', async () => { + api.getAllContent.mockResolvedValue({ results: 'not-an-array' }); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(result.current.error).toBe('Failed to load content.'); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch movie details successfully', async () => { + const mockResponse = { + id: 1, + name: 'Test Movie', + description: 'A test movie', + year: 2023, + url: 'http://example.com/movie.mp4', + }; + + api.getMovieDetails.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + let movieDetails; + await act(async () => { + movieDetails = await result.current.fetchMovieDetails(1); + }); + + expect(api.getMovieDetails).toHaveBeenCalledWith(1); + expect(movieDetails.id).toBe(1); + expect(movieDetails.name).toBe('Test Movie'); + expect(movieDetails.stream_url).toBe('http://example.com/movie.mp4'); + expect(result.current.content['movie_1']).toBeDefined(); + expect(result.current.content['movie_1'].contentType).toBe('movie'); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch movie details error', async () => { + const mockError = new Error('Not found'); + api.getMovieDetails.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchMovieDetails(999); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(result.current.error).toBe('Failed to load movie details.'); + expect(result.current.loading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch movie details from provider without merging to store', async () => { + const mockResponse = { + id: 1, + name: 'Provider Movie', + plot: 'From provider', + stream_url: 'http://provider.com/movie.mp4', + backdrop_path: ['path1', 'path2'], + }; + + api.getMovieProviderInfo.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + let movieDetails; + await act(async () => { + movieDetails = await result.current.fetchMovieDetailsFromProvider(1); + }); + + expect(api.getMovieProviderInfo).toHaveBeenCalledWith(1); + expect(movieDetails.name).toBe('Provider Movie'); + expect(movieDetails.description).toBe('From provider'); + expect(movieDetails.backdrop_path).toEqual(['path1', 'path2']); + expect(result.current.content['movie_1']).toBeUndefined(); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch movie provider error', async () => { + const mockError = new Error('Provider error'); + api.getMovieProviderInfo.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchMovieDetailsFromProvider(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(result.current.error).toBe('Failed to load movie details from provider.'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch movie providers successfully', async () => { + const mockProviders = [ + { id: 1, name: 'Provider 1' }, + { id: 2, name: 'Provider 2' }, + ]; + + api.getMovieProviders.mockResolvedValue(mockProviders); + + const { result } = renderHook(() => useVODStore()); + + let providers; + await act(async () => { + providers = await result.current.fetchMovieProviders(1); + }); + + expect(api.getMovieProviders).toHaveBeenCalledWith(1); + expect(providers).toEqual(mockProviders); + }); + + it('should handle fetch movie providers error', async () => { + const mockError = new Error('Providers error'); + api.getMovieProviders.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchMovieProviders(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch series providers successfully', async () => { + const mockProviders = [{ id: 1, name: 'Series Provider 1' }]; + + api.getSeriesProviders.mockResolvedValue(mockProviders); + + const { result } = renderHook(() => useVODStore()); + + let providers; + await act(async () => { + providers = await result.current.fetchSeriesProviders(1); + }); + + expect(api.getSeriesProviders).toHaveBeenCalledWith(1); + expect(providers).toEqual(mockProviders); + }); + + it('should handle fetch series providers error', async () => { + const mockError = new Error('Series providers error'); + api.getSeriesProviders.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchSeriesProviders(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch series info successfully', async () => { + const mockResponse = { + id: 1, + name: 'Test Series', + description: 'A test series', + year: 2023, + cover: 'http://example.com/cover.jpg', + episodes: { + 1: [ + { + id: 101, + title: 'Episode 1', + episode_number: 1, + plot: 'First episode', + }, + { + id: 102, + title: 'Episode 2', + episode_number: 2, + plot: 'Second episode', + }, + ], + }, + }; + + api.getSeriesInfo.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + let seriesInfo; + await act(async () => { + seriesInfo = await result.current.fetchSeriesInfo(1); + }); + + expect(api.getSeriesInfo).toHaveBeenCalledWith(1); + expect(seriesInfo.id).toBe(1); + expect(seriesInfo.name).toBe('Test Series'); + expect(seriesInfo.episodesList).toHaveLength(2); + expect(result.current.content['series_1']).toBeDefined(); + expect(result.current.content['series_1'].contentType).toBe('series'); + expect(result.current.episodes[101]).toBeDefined(); + expect(result.current.episodes[102]).toBeDefined(); + expect(result.current.episodes[101].name).toBe('Episode 1'); + expect(result.current.loading).toBe(false); + }); + + it('should handle fetch series info error', async () => { + const mockError = new Error('Series not found'); + api.getSeriesInfo.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + try { + await result.current.fetchSeriesInfo(999); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(result.current.error).toBe('Failed to load series details.'); + expect(result.current.loading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should fetch categories successfully with array response', async () => { + const mockCategories = [ + { id: 1, name: 'Action' }, + { id: 2, name: 'Comedy' }, + ]; + + api.getVODCategories.mockResolvedValue(mockCategories); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchCategories(); + }); + + expect(api.getVODCategories).toHaveBeenCalled(); + expect(result.current.categories).toEqual({ + 1: { id: 1, name: 'Action' }, + 2: { id: 2, name: 'Comedy' }, + }); + }); + + it('should fetch categories successfully with paginated response', async () => { + const mockResponse = { + results: [ + { id: 1, name: 'Drama' }, + { id: 2, name: 'Thriller' }, + ], + }; + + api.getVODCategories.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchCategories(); + }); + + expect(result.current.categories).toEqual({ + 1: { id: 1, name: 'Drama' }, + 2: { id: 2, name: 'Thriller' }, + }); + }); + + it('should handle fetch categories error', async () => { + const mockError = new Error('Categories error'); + api.getVODCategories.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODStore()); + + await act(async () => { + await result.current.fetchCategories(); + }); + + expect(result.current.error).toBe('Failed to load categories.'); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should add movie to content', () => { + const { result } = renderHook(() => useVODStore()); + const movie = { id: 1, name: 'New Movie' }; + + act(() => { + result.current.addMovie(movie); + }); + + expect(result.current.content['movie_1']).toEqual({ + id: 1, + name: 'New Movie', + contentType: 'movie', + }); + }); + + it('should update movie in content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Old Movie', contentType: 'movie' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const updatedMovie = { id: 1, name: 'Updated Movie' }; + + act(() => { + result.current.updateMovie(updatedMovie); + }); + + expect(result.current.content['movie_1']).toEqual({ + id: 1, + name: 'Updated Movie', + contentType: 'movie', + }); + }); + + it('should remove movie from content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie to Remove', contentType: 'movie' }, + movie_2: { id: 2, name: 'Movie to Keep', contentType: 'movie' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.removeMovie(1); + }); + + expect(result.current.content['movie_1']).toBeUndefined(); + expect(result.current.content['movie_2']).toBeDefined(); + }); + + it('should add series to content', () => { + const { result } = renderHook(() => useVODStore()); + const series = { id: 1, name: 'New Series' }; + + act(() => { + result.current.addSeries(series); + }); + + expect(result.current.content['series_1']).toEqual({ + id: 1, + name: 'New Series', + contentType: 'series', + }); + }); + + it('should update series in content', () => { + useVODStore.setState({ + content: { + series_1: { id: 1, name: 'Old Series', contentType: 'series' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const updatedSeries = { id: 1, name: 'Updated Series' }; + + act(() => { + result.current.updateSeries(updatedSeries); + }); + + expect(result.current.content['series_1']).toEqual({ + id: 1, + name: 'Updated Series', + contentType: 'series', + }); + }); + + it('should remove series from content', () => { + useVODStore.setState({ + content: { + series_1: { id: 1, name: 'Series to Remove', contentType: 'series' }, + series_2: { id: 2, name: 'Series to Keep', contentType: 'series' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.removeSeries(1); + }); + + expect(result.current.content['series_1']).toBeUndefined(); + expect(result.current.content['series_2']).toBeDefined(); + }); + + it('should get filtered content from current page', () => { + const mockContent = [ + { id: 1, name: 'Movie 1', contentType: 'movie' }, + { id: 2, name: 'Series 1', contentType: 'series' }, + ]; + + useVODStore.setState({ + currentPageContent: mockContent, + }); + + const { result } = renderHook(() => useVODStore()); + const filtered = result.current.getFilteredContent(); + + expect(filtered).toEqual(mockContent); + }); + + it('should get only movies from content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' }, + series_1: { id: 2, name: 'Series 1', contentType: 'series' }, + movie_2: { id: 3, name: 'Movie 2', contentType: 'movie' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const movies = result.current.getMovies(); + + expect(movies).toHaveLength(2); + expect(movies.every((item) => item.contentType === 'movie')).toBe(true); + }); + + it('should get only series from content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' }, + series_1: { id: 2, name: 'Series 1', contentType: 'series' }, + series_2: { id: 3, name: 'Series 2', contentType: 'series' }, + }, + }); + + const { result } = renderHook(() => useVODStore()); + const series = result.current.getSeries(); + + expect(series).toHaveLength(2); + expect(series.every((item) => item.contentType === 'series')).toBe(true); + }); + + it('should clear all content', () => { + useVODStore.setState({ + content: { + movie_1: { id: 1, name: 'Movie 1', contentType: 'movie' }, + series_1: { id: 2, name: 'Series 1', contentType: 'series' }, + }, + totalCount: 2, + }); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.clearContent(); + }); + + expect(result.current.content).toEqual({}); + expect(result.current.totalCount).toBe(0); + }); + + it('should handle fetch content with search filter', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Searched Movie', content_type: 'movie' }], + count: 1, + }; + + api.getAllContent.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ search: 'Searched' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + expect(api.getAllContent).toHaveBeenCalled(); + const callArgs = api.getAllContent.mock.calls[0][0]; + expect(callArgs.get('search')).toBe('Searched'); + }); + + it('should handle fetch content with category filter', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Action Movie', content_type: 'movie' }], + count: 1, + }; + + api.getAllContent.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.setFilters({ category: 'action' }); + }); + + await act(async () => { + await result.current.fetchContent(); + }); + + const callArgs = api.getAllContent.mock.calls[0][0]; + expect(callArgs.get('category')).toBe('action'); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getAllContent.mockReturnValue(promise); + + const { result } = renderHook(() => useVODStore()); + + act(() => { + result.current.fetchContent(); + }); + + expect(result.current.loading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise({ results: [], count: 0 }); + await promise; + }); + + expect(result.current.loading).toBe(false); + }); +}); diff --git a/frontend/src/store/__tests__/useVideoStore.test.jsx b/frontend/src/store/__tests__/useVideoStore.test.jsx new file mode 100644 index 00000000..b1f87d6d --- /dev/null +++ b/frontend/src/store/__tests__/useVideoStore.test.jsx @@ -0,0 +1,182 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import useVideoStore from '../useVideoStore'; + +describe('useVideoStore', () => { + beforeEach(() => { + useVideoStore.setState({ + isVisible: false, + streamUrl: null, + contentType: 'live', + metadata: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useVideoStore()); + + expect(result.current.isVisible).toBe(false); + expect(result.current.streamUrl).toBe(null); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should show video with live stream', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/stream.ts'; + + act(() => { + result.current.showVideo(streamUrl); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should show video with VOD content', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/video.mp4'; + const metadata = { title: 'Test Video', duration: 120 }; + + act(() => { + result.current.showVideo(streamUrl, 'vod', metadata); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('vod'); + expect(result.current.metadata).toEqual(metadata); + }); + + it('should show video with custom content type', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/video.mkv'; + + act(() => { + result.current.showVideo(streamUrl, 'vod'); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('vod'); + expect(result.current.metadata).toBe(null); + }); + + it('should hide video and reset state', () => { + const { result } = renderHook(() => useVideoStore()); + + act(() => { + result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' }); + }); + + expect(result.current.isVisible).toBe(true); + + act(() => { + result.current.hideVideo(); + }); + + expect(result.current.isVisible).toBe(false); + expect(result.current.streamUrl).toBe(null); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should update stream when showing different video', () => { + const { result } = renderHook(() => useVideoStore()); + const firstUrl = 'http://example.com/stream1.ts'; + const secondUrl = 'http://example.com/stream2.ts'; + + act(() => { + result.current.showVideo(firstUrl); + }); + + expect(result.current.streamUrl).toBe(firstUrl); + + act(() => { + result.current.showVideo(secondUrl); + }); + + expect(result.current.streamUrl).toBe(secondUrl); + expect(result.current.isVisible).toBe(true); + }); + + it('should handle showing video with null metadata explicitly', () => { + const { result } = renderHook(() => useVideoStore()); + const streamUrl = 'http://example.com/stream.ts'; + + act(() => { + result.current.showVideo(streamUrl, 'live', null); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(streamUrl); + expect(result.current.contentType).toBe('live'); + expect(result.current.metadata).toBe(null); + }); + + it('should preserve metadata when showing VOD content', () => { + const { result } = renderHook(() => useVideoStore()); + const metadata = { + title: 'Test Video', + duration: 120, + thumbnailUrl: 'http://example.com/thumb.jpg' + }; + + act(() => { + result.current.showVideo('http://example.com/video.mp4', 'vod', metadata); + }); + + expect(result.current.metadata).toEqual(metadata); + expect(result.current.metadata.title).toBe('Test Video'); + expect(result.current.metadata.duration).toBe(120); + }); + + it('should override previous metadata when showing new video', () => { + const { result } = renderHook(() => useVideoStore()); + const firstMetadata = { title: 'First Video' }; + const secondMetadata = { title: 'Second Video' }; + + act(() => { + result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata); + }); + + expect(result.current.metadata).toEqual(firstMetadata); + + act(() => { + result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata); + }); + + expect(result.current.metadata).toEqual(secondMetadata); + }); + + it('should handle hiding video when already hidden', () => { + const { result } = renderHook(() => useVideoStore()); + + expect(result.current.isVisible).toBe(false); + + act(() => { + result.current.hideVideo(); + }); + + expect(result.current.isVisible).toBe(false); + expect(result.current.streamUrl).toBe(null); + }); + + it('should handle showing video multiple times consecutively', () => { + const { result } = renderHook(() => useVideoStore()); + const url1 = 'http://example.com/stream1.ts'; + const url2 = 'http://example.com/stream2.ts'; + const url3 = 'http://example.com/stream3.ts'; + + act(() => { + result.current.showVideo(url1); + result.current.showVideo(url2); + result.current.showVideo(url3); + }); + + expect(result.current.isVisible).toBe(true); + expect(result.current.streamUrl).toBe(url3); + }); +}); diff --git a/frontend/src/store/__tests__/userAgents.test.jsx b/frontend/src/store/__tests__/userAgents.test.jsx new file mode 100644 index 00000000..8e804bec --- /dev/null +++ b/frontend/src/store/__tests__/userAgents.test.jsx @@ -0,0 +1,277 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useUserAgentsStore from '../userAgents'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useUserAgentsStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUserAgentsStore.setState({ + userAgents: [], + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useUserAgentsStore()); + + expect(result.current.userAgents).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch user agents successfully', async () => { + const mockUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]; + + api.getUserAgents.mockResolvedValue(mockUserAgents); + + const { result } = renderHook(() => useUserAgentsStore()); + + await act(async () => { + await result.current.fetchUserAgents(); + }); + + expect(api.getUserAgents).toHaveBeenCalled(); + expect(result.current.userAgents).toEqual(mockUserAgents); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch user agents error', async () => { + const mockError = new Error('Network error'); + api.getUserAgents.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useUserAgentsStore()); + + await act(async () => { + await result.current.fetchUserAgents(); + }); + + expect(result.current.error).toBe('Failed to load userAgents.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getUserAgents.mockReturnValue(promise); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.fetchUserAgents(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add user agent', () => { + useUserAgentsStore.setState({ + userAgents: [{ id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const newUserAgent = { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }; + + act(() => { + result.current.addUserAgent(newUserAgent); + }); + + expect(result.current.userAgents).toEqual([ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should add user agent to empty user agents', () => { + const { result } = renderHook(() => useUserAgentsStore()); + const newUserAgent = { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }; + + act(() => { + result.current.addUserAgent(newUserAgent); + }); + + expect(result.current.userAgents).toEqual([newUserAgent]); + }); + + it('should update user agent', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }; + + act(() => { + result.current.updateUserAgent(updatedUserAgent); + }); + + expect(result.current.userAgents).toEqual([ + { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should not modify other user agents when updating', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }; + + act(() => { + result.current.updateUserAgent(updatedUserAgent); + }); + + expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }); + }); + + it('should not modify user agents when updating non-existent user agent', () => { + const initialUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]; + + useUserAgentsStore.setState({ + userAgents: initialUserAgents, + }); + + const { result } = renderHook(() => useUserAgentsStore()); + const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' }; + + act(() => { + result.current.updateUserAgent(nonExistentUserAgent); + }); + + expect(result.current.userAgents).toEqual(initialUserAgents); + }); + + it('should remove single user agent', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + { id: 3, name: 'Safari', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([2]); + }); + + expect(result.current.userAgents).toEqual([ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 3, name: 'Safari', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should remove multiple user agents', () => { + useUserAgentsStore.setState({ + userAgents: [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + { id: 3, name: 'Safari', string: 'Mozilla/5.0...' }, + ], + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([1, 3]); + }); + + expect(result.current.userAgents).toEqual([ + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]); + }); + + it('should handle removing non-existent user agents', () => { + const initialUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + { id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }, + ]; + + useUserAgentsStore.setState({ + userAgents: initialUserAgents, + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([999]); + }); + + expect(result.current.userAgents).toEqual(initialUserAgents); + }); + + it('should handle removing from empty user agents', () => { + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([1, 2]); + }); + + expect(result.current.userAgents).toEqual([]); + }); + + it('should handle empty array when removing user agents', () => { + const initialUserAgents = [ + { id: 1, name: 'Chrome', string: 'Mozilla/5.0...' }, + ]; + + useUserAgentsStore.setState({ + userAgents: initialUserAgents, + }); + + const { result } = renderHook(() => useUserAgentsStore()); + + act(() => { + result.current.removeUserAgents([]); + }); + + expect(result.current.userAgents).toEqual(initialUserAgents); + }); + + it('should handle fetch with empty results', async () => { + api.getUserAgents.mockResolvedValue([]); + + const { result } = renderHook(() => useUserAgentsStore()); + + await act(async () => { + await result.current.fetchUserAgents(); + }); + + expect(result.current.userAgents).toEqual([]); + }); +}); diff --git a/frontend/src/store/__tests__/users.test.jsx b/frontend/src/store/__tests__/users.test.jsx new file mode 100644 index 00000000..58b343ba --- /dev/null +++ b/frontend/src/store/__tests__/users.test.jsx @@ -0,0 +1,258 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useUsersStore from '../users'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useUsersStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useUsersStore.setState({ + users: [], + isLoading: false, + error: null, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useUsersStore()); + + expect(result.current.users).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should fetch users successfully', async () => { + const mockUsers = [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]; + + api.getUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useUsersStore()); + + await act(async () => { + await result.current.fetchUsers(); + }); + + expect(api.getUsers).toHaveBeenCalled(); + expect(result.current.users).toEqual(mockUsers); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + it('should handle fetch users error', async () => { + const mockError = new Error('Network error'); + api.getUsers.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useUsersStore()); + + await act(async () => { + await result.current.fetchUsers(); + }); + + expect(result.current.error).toBe('Failed to load users.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getUsers.mockReturnValue(promise); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.fetchUsers(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should add user', () => { + useUsersStore.setState({ + users: [{ id: 1, name: 'User 1', email: 'user1@example.com' }], + }); + + const { result } = renderHook(() => useUsersStore()); + const newUser = { id: 2, name: 'User 2', email: 'user2@example.com' }; + + act(() => { + result.current.addUser(newUser); + }); + + expect(result.current.users).toEqual([ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]); + }); + + it('should add user to empty users', () => { + const { result } = renderHook(() => useUsersStore()); + const newUser = { id: 1, name: 'User 1', email: 'user1@example.com' }; + + act(() => { + result.current.addUser(newUser); + }); + + expect(result.current.users).toEqual([newUser]); + }); + + it('should update user', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' }; + + act(() => { + result.current.updateUser(updatedUser); + }); + + expect(result.current.users).toEqual([ + { id: 1, name: 'Updated User', email: 'updated@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]); + }); + + it('should not modify other users when updating', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' }; + + act(() => { + result.current.updateUser(updatedUser); + }); + + expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' }); + }); + + it('should not modify users when updating non-existent user', () => { + const initialUsers = [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]; + + useUsersStore.setState({ + users: initialUsers, + }); + + const { result } = renderHook(() => useUsersStore()); + const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' }; + + act(() => { + result.current.updateUser(nonExistentUser); + }); + + expect(result.current.users).toEqual(initialUsers); + }); + + it('should remove user', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + { id: 3, name: 'User 3', email: 'user3@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(2); + }); + + expect(result.current.users).toEqual([ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 3, name: 'User 3', email: 'user3@example.com' }, + ]); + }); + + it('should handle removing non-existent user', () => { + const initialUsers = [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + ]; + + useUsersStore.setState({ + users: initialUsers, + }); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(999); + }); + + expect(result.current.users).toEqual(initialUsers); + }); + + it('should handle removing from empty users', () => { + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(1); + }); + + expect(result.current.users).toEqual([]); + }); + + it('should handle fetch with empty results', async () => { + api.getUsers.mockResolvedValue([]); + + const { result } = renderHook(() => useUsersStore()); + + await act(async () => { + await result.current.fetchUsers(); + }); + + expect(result.current.users).toEqual([]); + }); + + it('should not modify other users when removing', () => { + useUsersStore.setState({ + users: [ + { id: 1, name: 'User 1', email: 'user1@example.com' }, + { id: 2, name: 'User 2', email: 'user2@example.com' }, + { id: 3, name: 'User 3', email: 'user3@example.com' }, + ], + }); + + const { result } = renderHook(() => useUsersStore()); + + act(() => { + result.current.removeUser(2); + }); + + expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' }); + expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' }); + }); +}); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx new file mode 100644 index 00000000..6ac3cc44 --- /dev/null +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -0,0 +1,480 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import useVODLogosStore from '../vodLogos'; +import api from '../../api'; + +vi.mock('../../api'); + +describe('useVODLogosStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + useVODLogosStore.setState({ + vodLogos: {}, + logos: [], + isLoading: false, + hasLoaded: false, + error: null, + totalCount: 0, + currentPage: 1, + pageSize: 25, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useVODLogosStore()); + + expect(result.current.vodLogos).toEqual({}); + expect(result.current.logos).toEqual([]); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLoaded).toBe(false); + expect(result.current.error).toBe(null); + expect(result.current.totalCount).toBe(0); + expect(result.current.currentPage).toBe(1); + expect(result.current.pageSize).toBe(25); + }); + + it('should set VOD logos with normalized structure', () => { + const { result } = renderHook(() => useVODLogosStore()); + const mockLogos = [ + { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + ]; + + act(() => { + result.current.setVODLogos(mockLogos, 2); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + 2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + }); + expect(result.current.totalCount).toBe(2); + expect(result.current.hasLoaded).toBe(true); + }); + + it('should fetch VOD logos successfully with array response', async () => { + const mockLogos = [ + { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + ]; + + api.getVODLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos(); + }); + + expect(api.getVODLogos).toHaveBeenCalled(); + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + 2: { id: 2, name: 'Logo 2', url: 'http://example.com/logo2.png' }, + }); + expect(result.current.logos).toEqual(mockLogos); + expect(result.current.totalCount).toBe(2); + expect(result.current.isLoading).toBe(false); + expect(result.current.hasLoaded).toBe(true); + }); + + it('should fetch VOD logos successfully with paginated response', async () => { + const mockLogos = [ + { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + ]; + const mockResponse = { + results: mockLogos, + count: 10, + }; + + api.getVODLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos({ page: 1, page_size: 25 }); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1', url: 'http://example.com/logo1.png' }, + }); + expect(result.current.logos).toEqual(mockLogos); + expect(result.current.totalCount).toBe(10); + }); + + it('should handle fetch VOD logos error', async () => { + const mockError = new Error('Network error'); + api.getVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.fetchVODLogos(); + } catch (error) { + // Expected error + } + }); + + expect(result.current.error).toBe('Failed to load VOD logos.'); + expect(result.current.isLoading).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should set loading state during fetch', async () => { + let resolvePromise; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + api.getVODLogos.mockReturnValue(promise); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.fetchVODLogos(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBe(null); + + await act(async () => { + resolvePromise([]); + await promise; + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('should remove single VOD logo from state', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + 3: { id: 3, name: 'Logo 3' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + { id: 3, name: 'Logo 3' }, + ], + totalCount: 3, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.removeVODLogo(2); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1' }, + 3: { id: 3, name: 'Logo 3' }, + }); + expect(result.current.logos).toEqual([ + { id: 1, name: 'Logo 1' }, + { id: 3, name: 'Logo 3' }, + ]); + expect(result.current.totalCount).toBe(2); + }); + + it('should remove multiple VOD logos from state using _removeLogosFromState', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + 3: { id: 3, name: 'Logo 3' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + { id: 3, name: 'Logo 3' }, + ], + totalCount: 3, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current._removeLogosFromState([1, 3]); + }); + + expect(result.current.vodLogos).toEqual({ + 2: { id: 2, name: 'Logo 2' }, + }); + expect(result.current.logos).toEqual([ + { id: 2, name: 'Logo 2' }, + ]); + expect(result.current.totalCount).toBe(1); + }); + + it('should delete single VOD logo successfully', async () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + ], + totalCount: 2, + }); + + api.deleteVODLogo.mockResolvedValue({}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.deleteVODLogo(1); + }); + + expect(api.deleteVODLogo).toHaveBeenCalledWith(1); + expect(result.current.vodLogos).toEqual({ + 2: { id: 2, name: 'Logo 2' }, + }); + expect(result.current.logos).toEqual([ + { id: 2, name: 'Logo 2' }, + ]); + expect(result.current.totalCount).toBe(1); + }); + + it('should handle delete VOD logo error', async () => { + const mockError = new Error('Delete failed'); + api.deleteVODLogo.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.deleteVODLogo(1); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should delete multiple VOD logos successfully', async () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + 2: { id: 2, name: 'Logo 2' }, + 3: { id: 3, name: 'Logo 3' }, + }, + logos: [ + { id: 1, name: 'Logo 1' }, + { id: 2, name: 'Logo 2' }, + { id: 3, name: 'Logo 3' }, + ], + totalCount: 3, + }); + + api.deleteVODLogos.mockResolvedValue({}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.deleteVODLogos([1, 2]); + }); + + expect(api.deleteVODLogos).toHaveBeenCalledWith([1, 2]); + expect(result.current.vodLogos).toEqual({ + 3: { id: 3, name: 'Logo 3' }, + }); + expect(result.current.logos).toEqual([ + { id: 3, name: 'Logo 3' }, + ]); + expect(result.current.totalCount).toBe(1); + }); + + it('should handle delete multiple VOD logos error', async () => { + const mockError = new Error('Bulk delete failed'); + api.deleteVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.deleteVODLogos([1, 2]); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should cleanup unused VOD logos and refresh', async () => { + useVODLogosStore.setState({ + currentPage: 2, + pageSize: 10, + }); + + const mockCleanupResult = { deleted: 5 }; + const mockRefreshedLogos = [{ id: 1, name: 'Logo 1' }]; + + api.cleanupUnusedVODLogos.mockResolvedValue(mockCleanupResult); + api.getVODLogos.mockResolvedValue(mockRefreshedLogos); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + const cleanupResult = await result.current.cleanupUnusedVODLogos(); + expect(cleanupResult).toEqual(mockCleanupResult); + }); + + expect(api.cleanupUnusedVODLogos).toHaveBeenCalled(); + expect(api.getVODLogos).toHaveBeenCalledWith({ + page: 2, + page_size: 10, + }); + expect(result.current.logos).toEqual(mockRefreshedLogos); + }); + + it('should handle cleanup unused VOD logos error', async () => { + const mockError = new Error('Cleanup failed'); + api.cleanupUnusedVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.cleanupUnusedVODLogos(); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should clear VOD logos', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + hasLoaded: true, + totalCount: 1, + error: 'Some error', + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.clearVODLogos(); + }); + + expect(result.current.vodLogos).toEqual({}); + expect(result.current.logos).toEqual([]); + expect(result.current.hasLoaded).toBe(false); + expect(result.current.totalCount).toBe(0); + expect(result.current.error).toBe(null); + }); + + it('should handle removing non-existent logo', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + totalCount: 1, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current.removeVODLogo(999); + }); + + expect(result.current.vodLogos).toEqual({ + 1: { id: 1, name: 'Logo 1' }, + }); + expect(result.current.logos).toEqual([{ id: 1, name: 'Logo 1' }]); + expect(result.current.totalCount).toBe(1); + }); + + it('should handle fetch with empty response', async () => { + api.getVODLogos.mockResolvedValue([]); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos(); + }); + + expect(result.current.vodLogos).toEqual({}); + expect(result.current.logos).toEqual([]); + expect(result.current.totalCount).toBe(0); + }); + + it('should not allow totalCount to go below zero', () => { + useVODLogosStore.setState({ + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + totalCount: 1, + }); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current._removeLogosFromState([1, 2, 3]); // Remove more than exist + }); + + expect(result.current.totalCount).toBe(0); + }); + + it('should handle _removeLogosFromState with empty array', () => { + const initialState = { + vodLogos: { + 1: { id: 1, name: 'Logo 1' }, + }, + logos: [{ id: 1, name: 'Logo 1' }], + totalCount: 1, + }; + + useVODLogosStore.setState(initialState); + + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + result.current._removeLogosFromState([]); + }); + + expect(result.current.vodLogos).toEqual(initialState.vodLogos); + expect(result.current.logos).toEqual(initialState.logos); + expect(result.current.totalCount).toBe(1); + }); + + it('should fetch with custom params', async () => { + const mockLogos = [{ id: 1, name: 'Logo 1' }]; + api.getVODLogos.mockResolvedValue(mockLogos); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + await result.current.fetchVODLogos({ search: 'test', page: 2 }); + }); + + expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 }); + }); +}); diff --git a/frontend/src/store/__tests__/warnings.test.jsx b/frontend/src/store/__tests__/warnings.test.jsx new file mode 100644 index 00000000..500fbd8c --- /dev/null +++ b/frontend/src/store/__tests__/warnings.test.jsx @@ -0,0 +1,195 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach } from 'vitest'; +import useWarningsStore from '../warnings'; + +describe('useWarningsStore', () => { + beforeEach(() => { + useWarningsStore.setState({ + suppressedWarnings: {}, + }); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.suppressedWarnings).toEqual({}); + }); + + it('should suppress a warning', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + }); + }); + + it('should suppress multiple warnings', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + deleteProfile: true, + }); + }); + + it('should suppress warning with explicit true value', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream', true); + }); + + expect(result.current.suppressedWarnings.deleteStream).toBe(true); + }); + + it('should unsuppress a warning with explicit false value', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.suppressedWarnings.deleteStream).toBe(true); + + act(() => { + result.current.suppressWarning('deleteStream', false); + }); + + expect(result.current.suppressedWarnings.deleteStream).toBe(false); + }); + + it('should check if warning is suppressed', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(false); + + act(() => { + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(true); + }); + + it('should return false for non-existent warning', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.isWarningSuppressed('nonExistentAction')).toBe(false); + }); + + it('should reset all suppressions', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + result.current.suppressWarning('deleteUser'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + deleteProfile: true, + deleteUser: true, + }); + + act(() => { + result.current.resetSuppressions(); + }); + + expect(result.current.suppressedWarnings).toEqual({}); + }); + + it('should maintain other suppressions when adding new one', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + deleteProfile: true, + }); + }); + + it('should handle resetting when already empty', () => { + const { result } = renderHook(() => useWarningsStore()); + + expect(result.current.suppressedWarnings).toEqual({}); + + act(() => { + result.current.resetSuppressions(); + }); + + expect(result.current.suppressedWarnings).toEqual({}); + }); + + it('should handle checking suppression after unsuppressing', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream', true); + }); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(true); + + act(() => { + result.current.suppressWarning('deleteStream', false); + }); + + expect(result.current.isWarningSuppressed('deleteStream')).toBe(false); + }); + + it('should preserve other warnings when unsuppressing one', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteProfile'); + }); + + act(() => { + result.current.suppressWarning('deleteStream', false); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: false, + deleteProfile: true, + }); + }); + + it('should handle suppressing same warning multiple times', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('deleteStream'); + result.current.suppressWarning('deleteStream'); + }); + + expect(result.current.suppressedWarnings).toEqual({ + deleteStream: true, + }); + }); + + it('should handle different action keys independently', () => { + const { result } = renderHook(() => useWarningsStore()); + + act(() => { + result.current.suppressWarning('action1'); + result.current.suppressWarning('action2', false); + }); + + expect(result.current.isWarningSuppressed('action1')).toBe(true); + expect(result.current.isWarningSuppressed('action2')).toBe(false); + expect(result.current.isWarningSuppressed('action3')).toBe(false); + }); +}); From cf1625fab91568beb3cadcdd95f852c530c18a4f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:41:25 -0800 Subject: [PATCH 15/63] Slight refactoring --- frontend/src/hooks/useLocalStorage.jsx | 2 +- frontend/src/store/auth.jsx | 17 +- frontend/src/store/channels.jsx | 196 ++++++++++---------- frontend/src/store/channelsTable.jsx | 3 - frontend/src/store/epgs.jsx | 55 +++--- frontend/src/store/logos.jsx | 33 ++-- frontend/src/store/useVODStore.jsx | 240 +++++++++++++------------ frontend/src/store/vodLogos.jsx | 61 +++---- 8 files changed, 312 insertions(+), 295 deletions(-) diff --git a/frontend/src/hooks/useLocalStorage.jsx b/frontend/src/hooks/useLocalStorage.jsx index 450c55df..d559d960 100644 --- a/frontend/src/hooks/useLocalStorage.jsx +++ b/frontend/src/hooks/useLocalStorage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; const useLocalStorage = (key, defaultValue) => { const localKey = key; diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 8fe943b7..e97e7e53 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -1,5 +1,4 @@ import { create } from 'zustand'; -import api from '../api'; import useSettingsStore from './settings'; import useChannelsStore from './channels'; import usePlaylistsStore from './playlists'; @@ -82,20 +81,16 @@ const useAuthStore = create((set, get) => ({ getToken: async () => { const tokenExpiration = localStorage.getItem('tokenExpiration'); - let accessToken = null; - if (isTokenExpired(tokenExpiration)) { - accessToken = await get().getRefreshToken(); - } else { - accessToken = localStorage.getItem('accessToken'); - } - return accessToken; + return isTokenExpired(tokenExpiration) + ? await get().getRefreshToken() + : localStorage.getItem('accessToken'); }, // Action to login login: async ({ username, password }) => { try { - const response = await api.login(username, password); + const response = await API.login(username, password); if (response.access) { const expiration = decodeToken(response.access); set({ @@ -121,8 +116,8 @@ const useAuthStore = create((set, get) => ({ if (!refreshToken) return false; // Add explicit return here try { - const data = await api.refreshToken(refreshToken); - if (data && data.access) { + const data = await API.refreshToken(refreshToken); + if (data?.access) { set({ accessToken: data.access, tokenExpiration: decodeToken(data.access), diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index 9fb958b2..a95959b2 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -1,9 +1,91 @@ import { create } from 'zustand'; import api from '../api'; -import { notifications } from '@mantine/notifications'; +import { showNotification } from '../utils/notificationUtils.js'; const defaultProfiles = { 0: { id: '0', name: 'All', channels: new Set() } }; +const reduceChannels = (channels) => { + const channelsByUUID = {}; + const channelsByID = channels.reduce((acc, channel) => { + acc[channel.id] = channel; + channelsByUUID[channel.uuid] = channel.id; + return acc; + }, {}); + return { channelsByUUID, channelsByID }; +} + +const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => { + if (currentStats.channels) { + if (oldChannels[ch.channel_id] === undefined) { + // Add null checks to prevent accessing properties on undefined + const channelId = channelsByUUID[ch.channel_id]; + const channel = channelId ? channels[channelId] : null; + + if (channel) { + showNotification({ + title: 'New channel streaming', + message: channel.name, + color: 'blue.5', + }); + } + } + } +} + +const showNotificationIfNewClient = (currentStats, oldClients, client) => { + // This check prevents the notifications if streams are active on page load + if (currentStats.channels) { + if (oldClients[client.client_id] === undefined) { + showNotification({ + title: 'New client started streaming', + message: `Client streaming from ${client.ip_address}`, + color: 'blue.5', + }); + } + } +} + +const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => { + // This check prevents the notifications if streams are active on page load + if (currentStats.channels) { + for (const uuid in oldChannels) { + if (newChannels[uuid] === undefined) { + // Add null check for channel name + const channelId = channelsByUUID[uuid]; + const channel = channelId && channels[channelId]; + + if (channel) { + showNotification({ + title: 'Channel streaming stopped', + message: channel.name, + color: 'blue.5', + }); + } else { + showNotification({ + title: 'Channel streaming stopped', + message: `Channel (${uuid})`, + color: 'blue.5', + }); + } + } + } + } +} + +const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => { + if (currentStats.channels) { + for (const clientId in oldClients) { + if (newClients[clientId] === undefined) { + showNotification({ + title: 'Client stopped streaming', + message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, + color: 'blue.5', + }); + } + } + } +} + const useChannelsStore = create((set, get) => ({ channels: [], channelsByUUID: {}, @@ -28,12 +110,7 @@ const useChannelsStore = create((set, get) => ({ set({ isLoading: true, error: null }); try { const channels = await api.getChannels(); - const channelsByUUID = {}; - const channelsByID = channels.reduce((acc, channel) => { - acc[channel.id] = channel; - channelsByUUID[channel.uuid] = channel.id; - return acc; - }, {}); + const { channelsByUUID, channelsByID } = reduceChannels(channels); set({ channels: channelsByID, channelsByUUID, @@ -113,16 +190,7 @@ const useChannelsStore = create((set, get) => ({ addChannels: (newChannels) => set((state) => { - const channelsByUUID = {}; - const profileChannels = new Set(); - - const channelsByID = newChannels.reduce((acc, channel) => { - acc[channel.id] = channel; - channelsByUUID[channel.uuid] = channel.id; - profileChannels.add(channel.id); - - return acc; - }, {}); + const { channelsByUUID, channelsByID } = reduceChannels(newChannels); // Don't automatically add to all profiles anymore - let the backend handle profile assignments // Just maintain the existing profile structure @@ -160,12 +228,8 @@ const useChannelsStore = create((set, get) => ({ ); return; } - const channelsByUUID = {}; - const updatedChannels = channels.reduce((acc, chan) => { - channelsByUUID[chan.uuid] = chan.id; - acc[chan.id] = chan; - return acc; - }, {}); + + const { channelsByUUID, updatedChannels } = reduceChannels(channels); set((state) => ({ channels: { @@ -249,12 +313,9 @@ const useChannelsStore = create((set, get) => ({ delete updatedProfiles[id]; } - let additionalUpdates = {}; - if (profileIds.includes(state.selectedProfileId)) { - additionalUpdates = { - selectedProfileId: '0', - }; - } + const additionalUpdates = profileIds.includes(state.selectedProfileId) + ? { selectedProfileId: '0' } + : {}; return { profiles: updatedProfiles, @@ -296,14 +357,12 @@ const useChannelsStore = create((set, get) => ({ channels: currentChannelsSet, }; - const updates = { + return { profiles: { ...state.profiles, [profileId]: updatedProfile, }, }; - - return updates; }), setChannelsPageSelection: (channelsPageSelection) => @@ -324,71 +383,24 @@ const useChannelsStore = create((set, get) => ({ channelsByUUID, } = state; const newClients = {}; + const newChannels = stats.channels.reduce((acc, ch) => { acc[ch.channel_id] = ch; - if (currentStats.channels) { - if (oldChannels[ch.channel_id] === undefined) { - // Add null checks to prevent accessing properties on undefined - const channelId = channelsByUUID[ch.channel_id]; - const channel = channelId ? channels[channelId] : null; - - if (channel) { - notifications.show({ - title: 'New channel streaming', - message: channel.name, - color: 'blue.5', - }); - } - } - } - ch.clients.map((client) => { - newClients[client.client_id] = client; - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - if (oldClients[client.client_id] === undefined) { - notifications.show({ - title: 'New client started streaming', - message: `Client streaming from ${client.ip_address}`, - color: 'blue.5', - }); - } - } - }); return acc; }, {}); - // This check prevents the notifications if streams are active on page load - if (currentStats.channels) { - for (const uuid in oldChannels) { - if (newChannels[uuid] === undefined) { - // Add null check for channel name - const channelId = channelsByUUID[uuid]; - const channel = channelId && channels[channelId]; - if (channel) { - notifications.show({ - title: 'Channel streaming stopped', - message: channel.name, - color: 'blue.5', - }); - } else { - notifications.show({ - title: 'Channel streaming stopped', - message: `Channel (${uuid})`, - color: 'blue.5', - }); - } - } - } - for (const clientId in oldClients) { - if (newClients[clientId] === undefined) { - notifications.show({ - title: 'Client stopped streaming', - message: `Client stopped streaming from ${oldClients[clientId].ip_address}`, - color: 'blue.5', - }); - } - } - } + stats.channels.forEach(ch => { + showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels); + + ch.clients.forEach(client => { + newClients[client.client_id] = client; + showNotificationIfNewClient(currentStats, oldClients, client); + }); + }); + + showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels); + showNotificationIfClientStopped(currentStats, oldClients, newClients); + return { stats, activeChannels: newChannels, diff --git a/frontend/src/store/channelsTable.jsx b/frontend/src/store/channelsTable.jsx index 75e14c63..c205a9be 100644 --- a/frontend/src/store/channelsTable.jsx +++ b/frontend/src/store/channelsTable.jsx @@ -1,7 +1,4 @@ import { create } from 'zustand'; -import api from '../api'; -import { notifications } from '@mantine/notifications'; -import API from '../api'; const useChannelsTableStore = create((set, get) => ({ channels: [], diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index 1760bc45..e8f5e2ad 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -1,6 +1,14 @@ import { create } from 'zustand'; import api from '../api'; +const determineEPGStatus = (data, currentEpg) => { + if (data.status) return data.status; + if (data.action === 'downloading') return 'fetching'; + if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing'; + if (data.progress === 100) return 'success'; + return currentEpg?.status || 'idle'; +}; + const useEPGsStore = create((set) => ({ epgs: {}, tvgs: [], @@ -92,7 +100,7 @@ const useEPGsStore = create((set) => ({ } // Create a new refreshProgress object that includes the current update - const newRefreshProgress = { + const refreshProgress = { ...state.refreshProgress, [data.source]: { action: data.action, @@ -106,45 +114,30 @@ const useEPGsStore = create((set) => ({ // Set the EPG source status based on the update // First prioritize explicit status values from the backend - const sourceStatus = data.status - ? data.status // Use explicit status if provided - : data.action === 'downloading' - ? 'fetching' - : data.action === 'parsing_channels' || - data.action === 'parsing_programs' - ? 'parsing' - : data.progress === 100 - ? 'success' // Mark as success when progress is 100% - : state.epgs[data.source]?.status || 'idle'; + const status = determineEPGStatus(data, state.epgs[data.source]); // Only update epgs object if status or last_message actually changed // This prevents unnecessary re-renders on every progress update - const currentEpg = state.epgs[data.source]; - const newLastMessage = - data.status === 'error' - ? data.error || 'Unknown error' - : currentEpg?.last_message; + const lastMessage = data.status === 'error' + ? (data.error || 'Unknown error') + : state.epgs[data.source]?.last_message; - let newEpgs = state.epgs; - if ( - currentEpg && - (currentEpg.status !== sourceStatus || - currentEpg.last_message !== newLastMessage) - ) { - newEpgs = { + const currentEpg = state.epgs[data.source]; + const shouldUpdateEpg = currentEpg && + (currentEpg.status !== status || currentEpg.last_message !== lastMessage); + + const epgs = shouldUpdateEpg + ? { ...state.epgs, [data.source]: { ...currentEpg, - status: sourceStatus, - last_message: newLastMessage, + status, + last_message: lastMessage, }, - }; - } + } + : state.epgs; - return { - refreshProgress: newRefreshProgress, - epgs: newEpgs, - }; + return { refreshProgress, epgs }; }), })); diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 5843b113..5e09cf02 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -1,6 +1,12 @@ import { create } from 'zustand'; import api from '../api'; +const getLogosArray = (response) => { + return Array.isArray(response) + ? response + : response.results || []; +} + const useLogosStore = create((set, get) => ({ logos: {}, channelLogos: {}, // Separate cache for channel forms to avoid reloading @@ -24,13 +30,12 @@ const useLogosStore = create((set, get) => ({ // Add to channelLogos if the user has loaded channel logos // This means they're using channel forms and the new logo should be available there - let newChannelLogos = state.channelLogos; - if (state.hasLoadedChannelLogos) { - newChannelLogos = { - ...state.channelLogos, - [newLogo.id]: { ...newLogo }, - }; - } + const newChannelLogos = state.hasLoadedChannelLogos + ? { + ...state.channelLogos, + [newLogo.id]: { ...newLogo }, + } + : state.channelLogos; return { logos: newLogos, @@ -75,7 +80,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogos({ page_size: pageSize }); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set({ logos: logos.reduce((acc, logo) => { @@ -109,9 +114,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogos({ no_pagination: 'true' }); // Handle both paginated and non-paginated responses - const logosArray = Array.isArray(response) - ? response - : response.results || []; + const logosArray = getLogosArray(response); set({ logos: logosArray.reduce((acc, logo) => { @@ -139,7 +142,7 @@ const useLogosStore = create((set, get) => ({ }); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set((state) => ({ logos: { @@ -190,7 +193,7 @@ const useLogosStore = create((set, get) => ({ const response = await api.getLogosByIds(missingIds); // Handle both paginated and non-paginated responses - const logos = Array.isArray(response) ? response : response.results || []; + const logos = getLogosArray(response); set((state) => ({ logos: { @@ -262,9 +265,7 @@ const useLogosStore = create((set, get) => ({ try { // Use the API directly to avoid interfering with the main isLoading state const response = await api.getLogos({ no_pagination: 'true' }); - const logosArray = Array.isArray(response) - ? response - : response.results || []; + const logosArray = getLogosArray(response); // Process logos in smaller chunks to avoid blocking the main thread const chunkSize = 1000; diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index 43edb1c9..defbfe17 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -1,6 +1,124 @@ import { create } from 'zustand'; import api from '../api'; +const getFetchContentParams = (state) => { + const params = new URLSearchParams(); + params.append('page', state.currentPage); + params.append('page_size', state.pageSize); + + if (state.filters.search) { + params.append('search', state.filters.search); + } + + if (state.filters.category) { + params.append('category', state.filters.category); + } + return params; +} + +const getMovieDetails = (response, movieId) => { + return { + id: response.id || movieId, + name: response.name || '', + description: response.description || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration_secs: response.duration_secs || null, + stream_url: response.url || '', + logo: response.logo_url || null, + type: 'movie', + director: response.director || '', + actors: response.actors || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + imdb_id: response.imdb_id || '', + m3u_account: response.m3u_account || '', + }; +} + +const getMovieDetailsWithProvider = (response, movieId) => { + return { + id: response.id || movieId, + name: response.name || '', + description: response.description || response.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + duration_secs: response.duration_secs || null, + stream_url: response.stream_url || '', + logo: response.logo || response.cover || null, + type: 'movie', + director: response.director || '', + actors: response.actors || response.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + youtube_trailer: response.youtube_trailer || '', + // Additional provider fields + backdrop_path: response.backdrop_path || [], + release_date: response.release_date || response.releasedate || '', + movie_image: response.movie_image || null, + o_name: response.o_name || '', + age: response.age || '', + episode_run_time: response.episode_run_time || null, + bitrate: response.bitrate || 0, + video: response.video || {}, + audio: response.audio || {}, + }; +} + +const getSeriesDetails = (response, seriesId) => { + return { + id: response.id || seriesId, + name: response.name || '', + description: response.description || response.custom_properties?.plot || '', + year: response.year || null, + genre: response.genre || '', + rating: response.rating || '', + logo: response.cover || null, + type: 'series', + director: response.custom_properties?.director || '', + cast: response.custom_properties?.cast || '', + country: response.country || '', + tmdb_id: response.tmdb_id || '', + imdb_id: response.imdb_id || '', + episode_count: response.episode_count || 0, + // Additional provider fields + backdrop_path: response.custom_properties?.backdrop_path || [], + release_date: response.release_date || '', + series_image: response.series_image || null, + o_name: response.o_name || '', + age: response.age || '', + m3u_account: response.m3u_account || '', + youtube_trailer: response.custom_properties?.youtube_trailer || '', + }; +} + +const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => { + return { + id: episode.id, + stream_id: episode.id, + name: episode.title || '', + description: episode.plot || '', + season_number: parseInt(seasonNumber) || 0, + episode_number: episode.episode_number || 0, + duration_secs: episode.duration_secs || null, + rating: episode.rating || '', + container_extension: episode.container_extension || '', + series: { + id: seriesInfo.id, + name: seriesInfo.name, + }, + type: 'episode', + uuid: episode.uuid, + logo: episode.movie_image ? { url: episode.movie_image } : null, + air_date: episode.air_date || null, + movie_image: episode.movie_image || null, + tmdb_id: episode.tmdb_id || '', + imdb_id: episode.imdb_id || '', + }; +} + const useVODStore = create((set, get) => ({ content: {}, // Store for individual content details (when fetching movie/series details) currentPageContent: [], // Store the current page's results @@ -39,17 +157,7 @@ const useVODStore = create((set, get) => ({ set({ loading: true, error: null }); const state = get(); - const params = new URLSearchParams(); - params.append('page', state.currentPage); - params.append('page_size', state.pageSize); - - if (state.filters.search) { - params.append('search', state.filters.search); - } - - if (state.filters.category) { - params.append('category', state.filters.category); - } + const params = getFetchContentParams(state); let allResults = []; let totalCount = 0; @@ -58,12 +166,14 @@ const useVODStore = create((set, get) => ({ // Fetch only movies const response = await api.getMovies(params); const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'movie' })); totalCount = response.count || results.length; } else if (state.filters.type === 'series') { // Fetch only series const response = await api.getSeries(params); const results = response.results || response; + allResults = results.map((item) => ({ ...item, contentType: 'series', @@ -73,16 +183,9 @@ const useVODStore = create((set, get) => ({ // Use the new unified backend endpoint for 'all' view const response = await api.getAllContent(params); console.log('getAllContent response:', response); - console.log('response type:', typeof response); - console.log( - 'response keys:', - response ? Object.keys(response) : 'no response' - ); const results = response.results || response; console.log('results:', results); - console.log('results type:', typeof results); - console.log('results is array:', Array.isArray(results)); // Check if results is actually an array before calling map if (!Array.isArray(results)) { @@ -140,24 +243,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getMovieDetails(movieId); // Transform the response data to match our expected format - const movieDetails = { - id: response.id || movieId, - name: response.name || '', - description: response.description || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - duration_secs: response.duration_secs || null, - stream_url: response.url || '', - logo: response.logo_url || null, - type: 'movie', - director: response.director || '', - actors: response.actors || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - imdb_id: response.imdb_id || '', - m3u_account: response.m3u_account || '', - }; + const movieDetails = getMovieDetails(response, movieId); console.log('Fetched Movie Details:', movieDetails); set((state) => ({ content: { @@ -184,33 +270,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getMovieProviderInfo(movieId); // Transform the response data to match our expected format - const movieDetails = { - id: response.id || movieId, - name: response.name || '', - description: response.description || response.plot || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - duration_secs: response.duration_secs || null, - stream_url: response.stream_url || '', - logo: response.logo || response.cover || null, - type: 'movie', - director: response.director || '', - actors: response.actors || response.cast || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - youtube_trailer: response.youtube_trailer || '', - // Additional provider fields - backdrop_path: response.backdrop_path || [], - release_date: response.release_date || response.releasedate || '', - movie_image: response.movie_image || null, - o_name: response.o_name || '', - age: response.age || '', - episode_run_time: response.episode_run_time || null, - bitrate: response.bitrate || 0, - video: response.video || {}, - audio: response.audio || {}, - }; + const movieDetails = getMovieDetailsWithProvider(response, movieId); set({ loading: false }); // Only update loading state @@ -316,31 +376,7 @@ const useVODStore = create((set, get) => ({ const response = await api.getSeriesInfo(seriesId); // Transform the response data to match our expected format - const seriesInfo = { - id: response.id || seriesId, - name: response.name || '', - description: - response.description || response.custom_properties?.plot || '', - year: response.year || null, - genre: response.genre || '', - rating: response.rating || '', - logo: response.cover || null, - type: 'series', - director: response.custom_properties?.director || '', - cast: response.custom_properties?.cast || '', - country: response.country || '', - tmdb_id: response.tmdb_id || '', - imdb_id: response.imdb_id || '', - episode_count: response.episode_count || 0, - // Additional provider fields - backdrop_path: response.custom_properties?.backdrop_path || [], - release_date: response.release_date || '', - series_image: response.series_image || null, - o_name: response.o_name || '', - age: response.age || '', - m3u_account: response.m3u_account || '', - youtube_trailer: response.custom_properties?.youtube_trailer || '', - }; + const seriesInfo = getSeriesDetails(response, seriesId); let episodesData = {}; @@ -349,29 +385,11 @@ const useVODStore = create((set, get) => ({ Object.entries(response.episodes).forEach( ([seasonNumber, seasonEpisodes]) => { seasonEpisodes.forEach((episode) => { - const episodeData = { - id: episode.id, - stream_id: episode.id, - name: episode.title || '', - description: episode.plot || '', - season_number: parseInt(seasonNumber) || 0, - episode_number: episode.episode_number || 0, - duration_secs: episode.duration_secs || null, - rating: episode.rating || '', - container_extension: episode.container_extension || '', - series: { - id: seriesInfo.id, - name: seriesInfo.name, - }, - type: 'episode', - uuid: episode.uuid, - logo: episode.movie_image ? { url: episode.movie_image } : null, - air_date: episode.air_date || null, - movie_image: episode.movie_image || null, - tmdb_id: episode.tmdb_id || '', - imdb_id: episode.imdb_id || '', - }; - episodesData[episode.id] = episodeData; + episodesData[episode.id] = getEpisodeDetails( + episode, + seasonNumber, + seriesInfo + ); }); } ); diff --git a/frontend/src/store/vodLogos.jsx b/frontend/src/store/vodLogos.jsx index 4df2dd17..34a30f2a 100644 --- a/frontend/src/store/vodLogos.jsx +++ b/frontend/src/store/vodLogos.jsx @@ -11,6 +11,29 @@ const useVODLogosStore = create((set) => ({ currentPage: 1, pageSize: 25, + _removeLogosFromState: (logoIds) => { + set((state) => { + const newVODLogos = { ...state.vodLogos }; + const logoIdSet = new Set(Array.isArray(logoIds) ? logoIds : [logoIds]); + + let removedCount = 0; + logoIdSet.forEach((id) => { + if (newVODLogos[id]) { + delete newVODLogos[id]; + removedCount++; + } + }); + + const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id)); + + return { + vodLogos: newVODLogos, + logos: newLogos, + totalCount: Math.max(0, state.totalCount - removedCount), + }; + }); + }, + setVODLogos: (logos, totalCount = 0) => { set({ vodLogos: logos.reduce((acc, logo) => { @@ -22,15 +45,10 @@ const useVODLogosStore = create((set) => ({ }); }, - removeVODLogo: (logoId) => - set((state) => { - const newVODLogos = { ...state.vodLogos }; - delete newVODLogos[logoId]; - return { - vodLogos: newVODLogos, - totalCount: Math.max(0, state.totalCount - 1), - }; - }), + removeVODLogo: (logoId) => { + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoId); + }, fetchVODLogos: async (params = {}) => { set({ isLoading: true, error: null }); @@ -62,16 +80,8 @@ const useVODLogosStore = create((set) => ({ deleteVODLogo: async (logoId) => { try { await api.deleteVODLogo(logoId); - set((state) => { - const newVODLogos = { ...state.vodLogos }; - delete newVODLogos[logoId]; - const newLogos = state.logos.filter((logo) => logo.id !== logoId); - return { - vodLogos: newVODLogos, - logos: newLogos, - totalCount: Math.max(0, state.totalCount - 1), - }; - }); + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoId); } catch (error) { console.error('Failed to delete VOD logo:', error); throw error; @@ -81,17 +91,8 @@ const useVODLogosStore = create((set) => ({ deleteVODLogos: async (logoIds) => { try { await api.deleteVODLogos(logoIds); - set((state) => { - const newVODLogos = { ...state.vodLogos }; - logoIds.forEach((id) => delete newVODLogos[id]); - const logoIdSet = new Set(logoIds); - const newLogos = state.logos.filter((logo) => !logoIdSet.has(logo.id)); - return { - vodLogos: newVODLogos, - logos: newLogos, - totalCount: Math.max(0, state.totalCount - logoIds.length), - }; - }); + const state = useVODLogosStore.getState(); + state._removeLogosFromState(logoIds); } catch (error) { console.error('Failed to delete VOD logos:', error); throw error; From 525ae60157ff4ea9f4de4db63e36b8313e2ad8af Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:41:50 -0800 Subject: [PATCH 16/63] Removed functionality not implemented --- .../src/components/tables/StreamsTable.jsx | 2 -- frontend/src/store/logos.jsx | 3 --- frontend/src/store/useVODStore.jsx | 24 ------------------- 3 files changed, 29 deletions(-) diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 21b13baf..c3b2f677 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -702,8 +702,6 @@ const StreamsTable = ({ onReady }) => { channel_profile_ids: channelProfileIds, }); await API.requeryChannels(); - const fetchLogos = useChannelsStore.getState().fetchLogos; - fetchLogos(); }; // Handle confirming the single channel numbering modal diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index 5e09cf02..f821c424 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -72,9 +72,6 @@ const useLogosStore = create((set, get) => ({ // Smart loading methods fetchLogos: async (pageSize = 100) => { - // Don't fetch if logo fetching is not allowed yet - if (!get().allowLogoFetching) return []; - set({ isLoading: true, error: null }); try { const response = await api.getLogos({ page_size: pageSize }); diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index defbfe17..0d8fe446 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -213,30 +213,6 @@ const useVODStore = create((set, get) => ({ } }, - fetchSeriesEpisodes: async (seriesId) => { - set({ loading: true, error: null }); - try { - const response = await api.getSeriesEpisodes(seriesId); - - set((state) => ({ - episodes: { - ...state.episodes, - ...response.reduce((acc, episode) => { - acc[episode.id] = episode; - return acc; - }, {}), - }, - loading: false, - })); - - return response; - } catch (error) { - console.error('Failed to fetch series episodes:', error); - set({ error: 'Failed to load episodes.', loading: false }); - throw error; // Re-throw to allow calling component to handle - } - }, - fetchMovieDetails: async (movieId) => { set({ loading: true, error: null }); try { From 04e2886ba53ea0655bf3587f2cd327c785323d6f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:09:01 -0800 Subject: [PATCH 17/63] Updated tests after sync --- frontend/src/store/__tests__/auth.test.jsx | 170 +++++++++++++++++- .../store/__tests__/channelsTable.test.jsx | 107 +++++++++++ .../src/store/__tests__/settings.test.jsx | 149 +++++++++++++++ .../src/store/__tests__/vodLogos.test.jsx | 70 ++++++++ 4 files changed, 487 insertions(+), 9 deletions(-) diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx index 4ae99e4b..7dc8277b 100644 --- a/frontend/src/store/__tests__/auth.test.jsx +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -40,9 +40,6 @@ const localStorageMock = (() => { global.localStorage = localStorageMock; -// Mock console methods -global.console.error = vi.fn(); - // Helper to create a mock JWT token const createMockToken = (expiresInSeconds = 3600) => { const now = Math.floor(Date.now() / 1000); @@ -161,7 +158,9 @@ describe('useAuthStore', () => { await result.current.login({ username: 'testuser', password: 'wrong' }); }); - expect(console.error).toHaveBeenCalledWith('Login failed:', expect.any(Error)); + expect(API.login).toHaveBeenCalledWith('testuser', 'wrong'); + expect(result.current.isAuthenticated).toBe(false); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); }); }); @@ -183,7 +182,6 @@ describe('useAuthStore', () => { expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token'); expect(newToken).toBe(mockNewAccessToken); - expect(result.current.isAuthenticated).toBe(true); expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken); }); @@ -212,7 +210,6 @@ describe('useAuthStore', () => { await result.current.getRefreshToken(); }); - expect(console.error).toHaveBeenCalledWith('Token refresh failed:', expect.any(Error)); expect(result.current.isAuthenticated).toBe(false); expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); @@ -292,7 +289,6 @@ describe('useAuthStore', () => { await result.current.logout(); }); - expect(console.error).toHaveBeenCalledWith('Logout API call failed:', expect.any(Error)); expect(result.current.isAuthenticated).toBe(false); expect(localStorageMock.removeItem).toHaveBeenCalled(); }); @@ -435,8 +431,6 @@ describe('useAuthStore', () => { await act(async () => { await result.current.initData(); }); - - expect(console.error).toHaveBeenCalledWith('Error initializing data:', expect.any(Error)); }); }); @@ -476,4 +470,162 @@ describe('useAuthStore', () => { expect(result.current.superuserExists).toBe(false); }); }); + + describe('getRefreshToken edge cases', () => { + it('should return false if API response has no access token', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + let response; + await act(async () => { + response = await result.current.getRefreshToken(); + }); + + expect(response).toBe(false); + }); + }); + + describe('login edge cases', () => { + it('should not update state if response has no access token', async () => { + API.login.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.login({ username: 'testuser', password: 'password' }); + }); + + expect(result.current.accessToken).toBeNull(); + expect(localStorageMock.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('logout edge cases', () => { + it('should reset isInitializing flag on logout', async () => { + useAuthStore.setState({ isInitializing: true }); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.isInitializing).toBe(false); + }); + }); + + describe('initializeAuth edge cases', () => { + it('should return false if refresh token API call fails', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockRejectedValue(new Error('Token expired')); + API.logout.mockResolvedValue(); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + + it('should return false if refresh returns no access token', async () => { + localStorageMock.getItem.mockReturnValue('refresh-token'); + API.refreshToken.mockResolvedValue({}); + + const { result } = renderHook(() => useAuthStore()); + + let initialized; + await act(async () => { + initialized = await result.current.initializeAuth(); + }); + + expect(initialized).toBe(false); + }); + }); + + describe('initData edge cases', () => { + it('should skip initialization if already initialized', async () => { + useAuthStore.setState({ isInitialized: true }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).not.toHaveBeenCalled(); + }); + + it('should skip initialization if already initializing', async () => { + useAuthStore.setState({ isInitializing: true }); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + expect(API.me).not.toHaveBeenCalled(); + }); + + it('should set isInitializing to false on error', async () => { + // Reset state before the test + useAuthStore.setState({ + isInitializing: false, + isInitialized: false + }); + + API.me.mockRejectedValue(new Error('API error')); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + try { + await result.current.initData(); + } catch { + // Expected error + } + }); + + expect(result.current.isInitializing).toBe(false); + expect(result.current.isInitialized).toBe(false); + }); + + it('should call fetchChannels in background after initialization', async () => { + const mockUser = { + username: 'admin', + email: 'admin@test.com', + user_level: USER_LEVELS.ADMIN, + }; + + const fetchChannels = vi.fn().mockResolvedValue(); + useChannelsStore.getState = () => ({ + fetchChannels, + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + }); + + API.me.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useAuthStore()); + + await act(async () => { + await result.current.initData(); + }); + + // Wait for the background call to complete + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + }); + + // The background fetchChannels is called synchronously without await + // so we just need to verify it was called + expect(fetchChannels).toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx index 171f0672..0bf3e31a 100644 --- a/frontend/src/store/__tests__/channelsTable.test.jsx +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -292,4 +292,111 @@ describe('useChannelsTableStore', () => { expect(result.current.sorting).toEqual([]); }); }); + + describe('isUnlocked', () => { + it('should initialize with default false value', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + expect(result.current.isUnlocked).toBe(false); + }); + }); + + describe('setIsUnlocked', () => { + it('should update isUnlocked to true', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setIsUnlocked(true); + }); + + expect(result.current.isUnlocked).toBe(true); + }); + + it('should update isUnlocked to false', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + act(() => { + result.current.setIsUnlocked(true); + }); + + expect(result.current.isUnlocked).toBe(true); + + act(() => { + result.current.setIsUnlocked(false); + }); + + expect(result.current.isUnlocked).toBe(false); + }); + }); + + describe('updateChannel', () => { + it('should update an existing channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Channel 2', channel_number: 2 }, + { id: 3, name: 'Channel 3', channel_number: 3 }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels).toEqual([ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Updated Channel 2', channel_number: 22 }, + { id: 3, name: 'Channel 3', channel_number: 3 }, + ]); + }); + + it('should not modify channels when updating non-existent channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1 }, + { id: 2, name: 'Channel 2', channel_number: 2 }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels).toEqual(mockChannels); + }); + + it('should preserve other channels when updating one channel', () => { + const { result } = renderHook(() => useChannelsTableStore()); + + const mockChannels = [ + { id: 1, name: 'Channel 1', channel_number: 1, streams: ['stream1'] }, + { id: 2, name: 'Channel 2', channel_number: 2, streams: ['stream2'] }, + ]; + + act(() => { + useChannelsTableStore.setState({ channels: mockChannels }); + }); + + const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 }; + + act(() => { + result.current.updateChannel(updatedChannel); + }); + + expect(result.current.channels[0]).toEqual(updatedChannel); + expect(result.current.channels[1]).toEqual(mockChannels[1]); + }); + }); }); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx index 59da4437..58f64724 100644 --- a/frontend/src/store/__tests__/settings.test.jsx +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -16,6 +16,10 @@ describe('useSettingsStore', () => { country_name: '', env_mode: 'prod', }, + version: { + version: '', + timestamp: null, + }, isLoading: false, error: null, }); @@ -201,4 +205,149 @@ describe('useSettingsStore', () => { expect(result.current.settings).toEqual({}); }); + + it('should initialize version with default state', () => { + const { result } = renderHook(() => useSettingsStore()); + + expect(result.current.version).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should fetch version successfully', async () => { + const mockVersion = { + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }; + + api.getVersion.mockResolvedValue(mockVersion); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(api.getVersion).toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }); + expect(versionResult).toEqual({ + version: '1.2.3', + timestamp: '2024-01-01T00:00:00Z', + }); + }); + + it('should skip fetching version if already loaded', async () => { + useSettingsStore.setState({ + version: { + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }, + }); + + api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(api.getVersion).not.toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + expect(versionResult).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + }); + + it('should handle null version response', async () => { + api.getVersion.mockResolvedValue(null); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchVersion(); + }); + + expect(result.current.version).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should handle fetch version error', async () => { + const mockError = new Error('Version fetch failed'); + api.getVersion.mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettingsStore()); + + let versionResult; + await act(async () => { + versionResult = await result.current.fetchVersion(); + }); + + expect(versionResult).toEqual({ + version: '', + timestamp: null, + }); + }); + + it('should fetch version with settings when version not loaded', async () => { + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + const mockEnv = { public_ip: '192.168.1.1' }; + const mockVersion = { version: '1.0.0', timestamp: '2024-01-01T00:00:00Z' }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + api.getVersion.mockResolvedValue(mockVersion); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getVersion).toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2024-01-01T00:00:00Z', + }); + }); + + it('should skip fetching version with settings when already loaded', async () => { + useSettingsStore.setState({ + version: { + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }, + }); + + const mockSettings = [{ key: 'setting1', value: 'value1' }]; + const mockEnv = { public_ip: '192.168.1.1' }; + + api.getSettings.mockResolvedValue(mockSettings); + api.getEnvironmentSettings.mockResolvedValue(mockEnv); + api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + + const { result } = renderHook(() => useSettingsStore()); + + await act(async () => { + await result.current.fetchSettings(); + }); + + expect(api.getVersion).not.toHaveBeenCalled(); + expect(result.current.version).toEqual({ + version: '1.0.0', + timestamp: '2023-01-01T00:00:00Z', + }); + }); }); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx index 6ac3cc44..116cbf9d 100644 --- a/frontend/src/store/__tests__/vodLogos.test.jsx +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -477,4 +477,74 @@ describe('useVODLogosStore', () => { expect(api.getVODLogos).toHaveBeenCalledWith({ search: 'test', page: 2 }); }); + + it('should get unused logos count successfully', async () => { + const mockResponse = { + results: [{ id: 1, name: 'Unused Logo' }], + count: 42, + }; + + api.getVODLogos.mockResolvedValue(mockResponse); + + const { result } = renderHook(() => useVODLogosStore()); + + let unusedCount; + await act(async () => { + unusedCount = await result.current.getUnusedLogosCount(); + }); + + expect(api.getVODLogos).toHaveBeenCalledWith({ + used: 'false', + page_size: 1, + }); + expect(unusedCount).toBe(42); + }); + + it('should return 0 when unused logos count response has no count', async () => { + api.getVODLogos.mockResolvedValue({ results: [] }); + + const { result } = renderHook(() => useVODLogosStore()); + + let unusedCount; + await act(async () => { + unusedCount = await result.current.getUnusedLogosCount(); + }); + + expect(unusedCount).toBe(0); + }); + + it('should handle get unused logos count error', async () => { + const mockError = new Error('Failed to fetch count'); + api.getVODLogos.mockRejectedValue(mockError); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { result } = renderHook(() => useVODLogosStore()); + + await act(async () => { + try { + await result.current.getUnusedLogosCount(); + } catch (error) { + expect(error).toBe(mockError); + } + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError); + + consoleErrorSpy.mockRestore(); + }); + + it('should update currentPage and pageSize state', () => { + const { result } = renderHook(() => useVODLogosStore()); + + act(() => { + useVODLogosStore.setState({ + currentPage: 3, + pageSize: 50, + }); + }); + + expect(result.current.currentPage).toBe(3); + expect(result.current.pageSize).toBe(50); + }); }); From c8430760c419bd5efa64e7d9478d3c77f9a457a7 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:15:26 -0800 Subject: [PATCH 18/63] Added tests after sync --- .../__tests__/useTablePreferences.test.jsx | 390 ++++++++++++++++++ .../src/store/__tests__/streamsTable.test.jsx | 349 ++++++++++++++++ 2 files changed, 739 insertions(+) create mode 100644 frontend/src/hooks/__tests__/useTablePreferences.test.jsx create mode 100644 frontend/src/store/__tests__/streamsTable.test.jsx diff --git a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx new file mode 100644 index 00000000..2158df54 --- /dev/null +++ b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useTablePreferences from '../useTablePreferences'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +global.localStorage = localStorageMock; + +describe('useTablePreferences', () => { + let consoleErrorSpy; + + beforeEach(() => { + // Spy on console.error + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + localStorageMock.clear(); + + // Mock window.addEventListener and removeEventListener + vi.spyOn(window, 'addEventListener'); + vi.spyOn(window, 'removeEventListener'); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + describe('Initial State', () => { + it('should initialize with default values when localStorage is empty', () => { + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(false); + expect(result.current.tableSize).toBe('default'); + }); + + it('should initialize headerPinned from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should initialize tableSize from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should initialize both preferences from localStorage', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should migrate tableSize from old localStorage location', () => { + localStorageMock.setItem('table-size', JSON.stringify('compact')); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should prefer new localStorage location over old location', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' })); + localStorageMock.setItem('table-size', JSON.stringify('compact')); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should handle malformed JSON in localStorage gracefully', () => { + localStorageMock.setItem('table-preferences', 'invalid json'); + + const { result } = renderHook(() => useTablePreferences()); + + expect(result.current.headerPinned).toBe(false); + expect(result.current.tableSize).toBe('default'); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('setHeaderPinned', () => { + it('should update headerPinned state', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should persist headerPinned to localStorage', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); + }); + + it('should preserve existing preferences when updating headerPinned', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ tableSize: 'compact', headerPinned: true }) + ); + }); + + it('should dispatch custom event when updating headerPinned', () => { + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'table-preferences-changed', + detail: { headerPinned: true }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('should handle localStorage errors gracefully', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving headerPinned to localStorage:', + expect.any(Error) + ); + }); + }); + + describe('setTableSize', () => { + it('should update tableSize state', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should persist tableSize to localStorage', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('comfortable'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ tableSize: 'comfortable' }) + ); + }); + + it('should preserve existing preferences when updating tableSize', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'compact' }) + ); + }); + + it('should dispatch custom event when updating tableSize', () => { + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent'); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'table-preferences-changed', + detail: { tableSize: 'compact' }, + }) + ); + + dispatchEventSpy.mockRestore(); + }); + + it('should handle localStorage errors gracefully', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({})); // Ensure it exists + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error saving tableSize to localStorage:', + expect.any(Error) + ); + }); + }); + + describe('Event Listeners', () => { + it('should register event listener on mount', () => { + renderHook(() => useTablePreferences()); + + expect(window.addEventListener).toHaveBeenCalledWith( + 'table-preferences-changed', + expect.any(Function) + ); + }); + + it('should remove event listener on unmount', () => { + const { unmount } = renderHook(() => useTablePreferences()); + + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith( + 'table-preferences-changed', + expect.any(Function) + ); + }); + + it('should update headerPinned from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(true); + }); + + it('should update tableSize from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { tableSize: 'compact' }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.tableSize).toBe('compact'); + }); + + it('should update both preferences from custom event', () => { + const { result } = renderHook(() => useTablePreferences()); + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true, tableSize: 'comfortable' }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('comfortable'); + }); + + it('should not update if value is the same', () => { + localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + + const { result } = renderHook(() => useTablePreferences()); + const initialHeaderPinned = result.current.headerPinned; + + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result.current.headerPinned).toBe(initialHeaderPinned); + }); + }); + + describe('Integration Tests', () => { + it('should handle complete workflow', () => { + const { result } = renderHook(() => useTablePreferences()); + + // Update headerPinned + act(() => { + result.current.setHeaderPinned(true); + }); + + expect(result.current.headerPinned).toBe(true); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); + + // Update tableSize + act(() => { + result.current.setTableSize('compact'); + }); + + expect(result.current.tableSize).toBe('compact'); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'compact' }) + ); + + // Verify both preferences are maintained + expect(result.current.headerPinned).toBe(true); + expect(result.current.tableSize).toBe('compact'); + }); + + it('should sync changes across multiple hook instances via events', () => { + const { result: result1 } = renderHook(() => useTablePreferences()); + const { result: result2 } = renderHook(() => useTablePreferences()); + + // Update from first instance + act(() => { + result1.current.setHeaderPinned(true); + }); + + // Second instance should receive the event + act(() => { + const event = new CustomEvent('table-preferences-changed', { + detail: { headerPinned: true }, + }); + window.dispatchEvent(event); + }); + + expect(result2.current.headerPinned).toBe(true); + }); + }); +}); diff --git a/frontend/src/store/__tests__/streamsTable.test.jsx b/frontend/src/store/__tests__/streamsTable.test.jsx new file mode 100644 index 00000000..62818107 --- /dev/null +++ b/frontend/src/store/__tests__/streamsTable.test.jsx @@ -0,0 +1,349 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import useStreamsTableStore from '../streamsTable'; + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }) + }; +})(); + +globalThis.localStorage = localStorageMock; + +describe('useStreamsTableStore', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Initial State', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.streams).toEqual([]); + expect(result.current.pageCount).toBe(0); + expect(result.current.totalCount).toBe(0); + expect(result.current.sorting).toEqual([{ id: 'name', desc: false }]); + expect(result.current.pagination).toEqual({ + pageIndex: 0, + pageSize: 50, + }); + expect(result.current.selectedStreamIds).toEqual([]); + expect(result.current.allQueryIds).toEqual([]); + expect(result.current.lastQueryParams).toBeNull(); + }); + + it('should initialize pagination with localStorage value if available', () => { + // Set localStorage BEFORE getting the store state + localStorageMock.setItem('streams-page-size', JSON.stringify(100)); + + // Now update the store to re-read from localStorage + useStreamsTableStore.setState({ + pagination: { + pageIndex: 0, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, + } + }); + + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.pagination.pageSize).toBe(100); + }); + + it('should use default page size if localStorage is empty', () => { + // Ensure localStorage is empty + localStorageMock.clear(); + + useStreamsTableStore.setState({ + pagination: { + pageIndex: 0, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, + } + }); + + const { result } = renderHook(() => useStreamsTableStore()); + + expect(result.current.pagination.pageSize).toBe(50); + }); + }); + + describe('queryStreams', () => { + it('should update streams, totalCount, and pageCount', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockResults = [ + { id: 1, name: 'Stream 1' }, + { id: 2, name: 'Stream 2' }, + ]; + const mockCount = 100; + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryStreams( + { results: mockResults, count: mockCount }, + mockParams + ); + }); + + expect(result.current.streams).toEqual(mockResults); + expect(result.current.totalCount).toBe(100); + expect(result.current.pageCount).toBe(2); // Math.ceil(100 / 50) + }); + + it('should calculate pageCount correctly with different page sizes', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryStreams( + { results: [], count: 75 }, + mockParams + ); + }); + + expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25) + }); + + it('should handle empty results', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page_size: '50' }); + + act(() => { + result.current.queryStreams( + { results: [], count: 0 }, + mockParams + ); + }); + + expect(result.current.streams).toEqual([]); + expect(result.current.totalCount).toBe(0); + expect(result.current.pageCount).toBe(0); + }); + }); + + describe('setAllQueryIds', () => { + it('should update allQueryIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockIds = [1, 2, 3, 4, 5]; + + act(() => { + result.current.setAllQueryIds(mockIds); + }); + + expect(result.current.allQueryIds).toEqual(mockIds); + }); + + it('should replace previous allQueryIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setAllQueryIds([1, 2, 3]); + }); + + act(() => { + result.current.setAllQueryIds([4, 5, 6]); + }); + + expect(result.current.allQueryIds).toEqual([4, 5, 6]); + }); + }); + + describe('setSelectedStreamIds', () => { + it('should update selectedStreamIds', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockIds = [1, 3, 5]; + + act(() => { + result.current.setSelectedStreamIds(mockIds); + }); + + expect(result.current.selectedStreamIds).toEqual(mockIds); + }); + + it('should handle empty selection', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setSelectedStreamIds([1, 2, 3]); + }); + + act(() => { + result.current.setSelectedStreamIds([]); + }); + + expect(result.current.selectedStreamIds).toEqual([]); + }); + }); + + describe('setPagination', () => { + it('should update pagination state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newPagination = { + pageIndex: 2, + pageSize: 100, + }; + + act(() => { + result.current.setPagination(newPagination); + }); + + expect(result.current.pagination).toEqual(newPagination); + }); + + it('should replace entire pagination object', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setPagination({ pageIndex: 1, pageSize: 25 }); + }); + + act(() => { + result.current.setPagination({ pageIndex: 3, pageSize: 75 }); + }); + + expect(result.current.pagination).toEqual({ pageIndex: 3, pageSize: 75 }); + }); + }); + + describe('setSorting', () => { + it('should update sorting state', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newSorting = [{ id: 'created_at', desc: true }]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle multiple sorting columns', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const newSorting = [ + { id: 'name', desc: false }, + { id: 'created_at', desc: true }, + ]; + + act(() => { + result.current.setSorting(newSorting); + }); + + expect(result.current.sorting).toEqual(newSorting); + }); + + it('should handle empty sorting array', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setSorting([]); + }); + + expect(result.current.sorting).toEqual([]); + }); + }); + + describe('setLastQueryParams', () => { + it('should update lastQueryParams', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + const mockParams = new URLSearchParams({ page: '1', search: 'test' }); + + act(() => { + result.current.setLastQueryParams(mockParams); + }); + + expect(result.current.lastQueryParams).toBe(mockParams); + }); + + it('should handle null value', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + act(() => { + result.current.setLastQueryParams(new URLSearchParams()); + }); + + act(() => { + result.current.setLastQueryParams(null); + }); + + expect(result.current.lastQueryParams).toBeNull(); + }); + }); + + describe('Integration Tests', () => { + it('should handle a typical query flow', () => { + const { result } = renderHook(() => useStreamsTableStore()); + + // Set pagination + act(() => { + result.current.setPagination({ pageIndex: 0, pageSize: 25 }); + }); + + // Set sorting + act(() => { + result.current.setSorting([{ id: 'created_at', desc: true }]); + }); + + // Query streams + const mockResults = [ + { id: 1, name: 'Stream 1' }, + { id: 2, name: 'Stream 2' }, + ]; + const mockParams = new URLSearchParams({ page_size: '25' }); + + act(() => { + result.current.queryStreams( + { results: mockResults, count: 50 }, + mockParams + ); + }); + + // Set query IDs + act(() => { + result.current.setAllQueryIds([1, 2, 3, 4, 5]); + }); + + // Select streams + act(() => { + result.current.setSelectedStreamIds([1, 2]); + }); + + // Set last query params + act(() => { + result.current.setLastQueryParams(mockParams); + }); + + expect(result.current.streams).toEqual(mockResults); + expect(result.current.totalCount).toBe(50); + expect(result.current.pageCount).toBe(2); + expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 }); + expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]); + expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]); + expect(result.current.selectedStreamIds).toEqual([1, 2]); + expect(result.current.lastQueryParams).toBe(mockParams); + }); + }); +}); From 37a2a7b52b047d61908373cd2d0c388ca01e6048 Mon Sep 17 00:00:00 2001 From: None Date: Wed, 28 Jan 2026 20:15:03 -0600 Subject: [PATCH 19/63] fix: Escape XML special characters in EPG channel IDs Fixed XML parsing errors in EPG output when channel IDs contain special characters. Changes: - Added html.escape() to apps/output/views.py where channel_id is used in XML attributes - Added unit tests in apps/output/tests.py to validate XML escaping for &, <, >, and " characters --- apps/output/tests.py | 106 +++++++++++++++++++++++++++++++++++++++++++ apps/output/views.py | 10 ++-- 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/apps/output/tests.py b/apps/output/tests.py index f87c8340..b1c7d589 100644 --- a/apps/output/tests.py +++ b/apps/output/tests.py @@ -1,5 +1,8 @@ from django.test import TestCase, Client from django.urls import reverse +from apps.channels.models import Channel, ChannelGroup +from apps.epg.models import EPGData, EPGSource +import xml.etree.ElementTree as ET class OutputM3UTest(TestCase): def setUp(self): @@ -37,3 +40,106 @@ class OutputM3UTest(TestCase): self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) + + +class OutputEPGXMLEscapingTest(TestCase): + """Test XML escaping of channel_id attributes in EPG generation""" + + def setUp(self): + self.client = Client() + self.group = ChannelGroup.objects.create(name="Test Group") + + def test_channel_id_with_ampersand(self): + """Test channel ID with ampersand is properly escaped""" + channel = Channel.objects.create( + channel_number=1.0, + name="Test Channel", + tvg_id="News & Sports", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # Should contain escaped ampersand + self.assertIn('id="News & Sports"', content) + self.assertNotIn('id="News & Sports"', content) + + # Verify XML is parseable + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG is not valid XML: {e}") + + def test_channel_id_with_angle_brackets(self): + """Test channel ID with < and > characters""" + channel = Channel.objects.create( + channel_number=2.0, + name="HD Channel", + tvg_id="Channel ", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Channel <HD>"', content) + + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG with < > is not valid XML: {e}") + + def test_channel_id_with_all_special_chars(self): + """Test channel ID with all XML special characters""" + channel = Channel.objects.create( + channel_number=3.0, + name="Complex Channel", + tvg_id='Test & "Special" ', + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Test & "Special" <Chars>"', content) + + try: + tree = ET.fromstring(content) + # Verify we can find the channel with correct ID in parsed tree + channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" "]') + self.assertIsNotNone(channel_elem) + except ET.ParseError as e: + self.fail(f"Generated EPG with all special chars is not valid XML: {e}") + + def test_program_channel_attribute_escaping(self): + """Test that programme elements also have escaped channel attributes""" + epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy") + epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source) + channel = Channel.objects.create( + channel_number=4.0, + name="Program Test", + tvg_id="News & Sports", + epg_data=epg_data, + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + + # Check programme elements have escaped channel attributes + self.assertIn('channel="News & Sports"', content) + + try: + tree = ET.fromstring(content) + programmes = tree.findall('.//programme[@channel="News & Sports"]') + self.assertGreater(len(programmes), 0) + except ET.ParseError as e: + self.fail(f"Generated EPG with programme elements is not valid XML: {e}") diff --git a/apps/output/views.py b/apps/output/views.py index ba75246a..58707ae2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -1203,7 +1203,7 @@ def generate_dummy_epg( # Create program entry with escaped channel name xml_lines.append( - f' ' + f' ' ) xml_lines.append(f" {html.escape(program['title'])}") xml_lines.append(f" {html.escape(program['description'])}") @@ -1448,7 +1448,7 @@ def generate_epg(request, profile_name=None, user=None): else: tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) display_name = channel.name - xml_lines.append(f' ') + xml_lines.append(f' ') xml_lines.append(f' {html.escape(display_name)}') xml_lines.append(f' ') xml_lines.append(" ") @@ -1523,7 +1523,7 @@ def generate_epg(request, profile_name=None, user=None): stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") # Create program entry with escaped channel name - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" @@ -1572,7 +1572,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") - yield f' \n' + yield f' \n' yield f" {html.escape(program['title'])}\n" yield f" {html.escape(program['description'])}\n" @@ -1634,7 +1634,7 @@ def generate_epg(request, profile_name=None, user=None): start_str = prog.start_time.strftime("%Y%m%d%H%M%S %z") stop_str = prog.end_time.strftime("%Y%m%d%H%M%S %z") - program_xml = [f' '] + program_xml = [f' '] program_xml.append(f' {html.escape(prog.title)}') # Add subtitle if available From 8de22fc44dc64b2558f77f3402db20a6e05111ff Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 10:22:07 -0600 Subject: [PATCH 20/63] Indent modular if statement for readability. --- docker/init/02-postgres.sh | 226 ++++++++++++++++++------------------- 1 file changed, 113 insertions(+), 113 deletions(-) diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh index a9237df0..972dea06 100644 --- a/docker/init/02-postgres.sh +++ b/docker/init/02-postgres.sh @@ -3,125 +3,125 @@ # Skip internal PostgreSQL setup in modular mode (using external database) if [[ "$DISPATCHARR_ENV" != "modular" ]]; then -# Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove -# some time in the future. -if [ -e "/data/postgresql.conf" ]; then - echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..." + # Temporary migration from postgres in /data to $POSTGRES_DIR. Can likely remove + # some time in the future. + if [ -e "/data/postgresql.conf" ]; then + echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..." - # Create a temporary directory outside of /data - mkdir -p /tmp/postgres_migration + # Create a temporary directory outside of /data + mkdir -p /tmp/postgres_migration - # Move the PostgreSQL files to the temporary directory - mv /data/* /tmp/postgres_migration/ + # Move the PostgreSQL files to the temporary directory + mv /data/* /tmp/postgres_migration/ - # Create the target directory - mkdir -p $POSTGRES_DIR + # Create the target directory + mkdir -p $POSTGRES_DIR - # Move the files from temporary directory to the final location - mv /tmp/postgres_migration/* $POSTGRES_DIR/ + # Move the files from temporary directory to the final location + mv /tmp/postgres_migration/* $POSTGRES_DIR/ - # Clean up the temporary directory - rmdir /tmp/postgres_migration + # Clean up the temporary directory + rmdir /tmp/postgres_migration - # Set proper ownership and permissions for PostgreSQL data directory - chown -R postgres:postgres $POSTGRES_DIR - chmod 700 $POSTGRES_DIR + # Set proper ownership and permissions for PostgreSQL data directory + chown -R postgres:postgres $POSTGRES_DIR + chmod 700 $POSTGRES_DIR - echo "Migration completed successfully." -fi + echo "Migration completed successfully." + fi -PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION" + PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION" -# Detect current version from data directory, if present -if [ -f "$PG_VERSION_FILE" ]; then - CURRENT_VERSION=$(cat "$PG_VERSION_FILE") -else - CURRENT_VERSION="" -fi + # Detect current version from data directory, if present + if [ -f "$PG_VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$PG_VERSION_FILE") + else + CURRENT_VERSION="" + fi -# Only run upgrade if current version is set and not the target -if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then - echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..." - # Set binary paths for upgrade if needed - OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin" - NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" - PG_INSTALLED_BY_SCRIPT=0 - if [ ! -d "$OLD_BINDIR" ]; then - echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..." - apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION - if [ $? -ne 0 ]; then - echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting." - exit 1 + # Only run upgrade if current version is set and not the target + if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then + echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..." + # Set binary paths for upgrade if needed + OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin" + NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" + PG_INSTALLED_BY_SCRIPT=0 + if [ ! -d "$OLD_BINDIR" ]; then + echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..." + apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION + if [ $? -ne 0 ]; then + echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting." + exit 1 + fi + PG_INSTALLED_BY_SCRIPT=1 + fi + + # Prepare new data directory + NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION" + + # Remove new data directory if it already exists (from a failed/partial upgrade) + if [ -d "$NEW_POSTGRES_DIR" ]; then + echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues." + rm -rf "$NEW_POSTGRES_DIR" + fi + + mkdir -p "$NEW_POSTGRES_DIR" + chown -R postgres:postgres "$NEW_POSTGRES_DIR" + chmod 700 "$NEW_POSTGRES_DIR" + + # Initialize new data directory + echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..." + su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR" + echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..." + # Run pg_upgrade + su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" + + # Move old data directory for backup, move new into place + mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)" + mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR" + + echo "Upgrade complete. Old data directory backed up." + + # Uninstall PostgreSQL if we installed it just for upgrade + if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then + echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..." + apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION + apt autoremove -y fi - PG_INSTALLED_BY_SCRIPT=1 fi - # Prepare new data directory - NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION" + # Initialize PostgreSQL database + if [ -z "$(ls -A $POSTGRES_DIR)" ]; then + echo "Initializing PostgreSQL database..." + mkdir -p $POSTGRES_DIR + chown -R postgres:postgres $POSTGRES_DIR + chmod 700 $POSTGRES_DIR - # Remove new data directory if it already exists (from a failed/partial upgrade) - if [ -d "$NEW_POSTGRES_DIR" ]; then - echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues." - rm -rf "$NEW_POSTGRES_DIR" - fi + # Initialize PostgreSQL + su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" + # Configure PostgreSQL + echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf" + echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf" - mkdir -p "$NEW_POSTGRES_DIR" - chown -R postgres:postgres "$NEW_POSTGRES_DIR" - chmod 700 "$NEW_POSTGRES_DIR" + # Start PostgreSQL + echo "Starting Postgres..." + su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + # Wait for PostgreSQL to be ready + until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo "Waiting for PostgreSQL to be ready..." + sleep 1 + done - # Initialize new data directory - echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..." - su - postgres -c "$NEW_BINDIR/initdb -D $NEW_POSTGRES_DIR" - echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..." - # Run pg_upgrade - su - postgres -c "$NEW_BINDIR/pg_upgrade -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" + postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') - # Move old data directory for backup, move new into place - mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)" - mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR" - - echo "Upgrade complete. Old data directory backed up." - - # Uninstall PostgreSQL if we installed it just for upgrade - if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then - echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..." - apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION - apt autoremove -y - fi -fi - -# Initialize PostgreSQL database -if [ -z "$(ls -A $POSTGRES_DIR)" ]; then - echo "Initializing PostgreSQL database..." - mkdir -p $POSTGRES_DIR - chown -R postgres:postgres $POSTGRES_DIR - chmod 700 $POSTGRES_DIR - - # Initialize PostgreSQL - su - postgres -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" - # Configure PostgreSQL - echo "host all all 0.0.0.0/0 md5" >> "${POSTGRES_DIR}/pg_hba.conf" - echo "listen_addresses='*'" >> "${POSTGRES_DIR}/postgresql.conf" - - # Start PostgreSQL - echo "Starting Postgres..." - su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" - # Wait for PostgreSQL to be ready - until su - postgres -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do - echo "Waiting for PostgreSQL to be ready..." - sleep 1 - done - - postgres_pid=$(su - postgres -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') - - # Setup database if needed - if ! su - postgres -c "psql -p ${POSTGRES_PORT} -tAc \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" | grep -q 1; then - # Create PostgreSQL database - echo "Creating PostgreSQL database..." - su - postgres -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}" - # Create user, set ownership, and grant privileges - echo "Creating PostgreSQL user..." - su - postgres -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" < Date: Thu, 29 Jan 2026 10:42:34 -0600 Subject: [PATCH 21/63] Bump modular postgres to 17 from 14 to match AIO. --- docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9d921c2d..c8e2d53e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -77,7 +77,7 @@ services: entrypoint: ["/app/docker/entrypoint.celery.sh"] db: - image: postgres:14 + image: postgres:17 container_name: dispatcharr_db ports: - "5436:5432" From 5f4e645a4896b128329821e1a2eec34eff1fe002 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 13:12:08 -0600 Subject: [PATCH 22/63] changelog: Update changelog for frontend tests/refactoring PR. --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 960302d2..a81a83bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including: + - `useLocalStorage` hook tests with localStorage mocking and error handling + - `useSmartLogos` hook tests for logo loading and management + - `useTablePreferences` hook tests for table settings persistence + - `useAuthStore` tests for authentication flow and token management + - `useChannelsStore` tests for channel data management + - `useUserAgentsStore` tests for user agent CRUD operations + - `useUsersStore` tests for user management functionality + - `useVODLogosStore` tests for VOD logo operations + - `useVideoStore` tests for video player state management + - `useWarningsStore` tests for warning suppression functionality + - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) + ### Changed - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: From 573b7d12abf163299121a2e0994500d48c2349c2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 13:26:16 -0600 Subject: [PATCH 23/63] changelog: Update changelog for EPG Channel ID xml escaping. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81a83bf..e28ecb11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). +- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) ## [0.18.1] - 2026-01-27 From 3a959c2743e9ec211e12db982da3574a00ac4a86 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 16:15:04 -0600 Subject: [PATCH 24/63] Fixed pip download (uv doesn't have an equivalent) --- docker/DispatcharrBase | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 4793102d..d89f334f 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -26,7 +26,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ # --- Create Python virtual environment and install dependencies --- WORKDIR /tmp/build COPY pyproject.toml /tmp/build/ -RUN uv venv $VIRTUAL_ENV --python 3.13 && \ +RUN uv venv $VIRTUAL_ENV --python 3.13 --seed && \ uv sync --python $VIRTUAL_ENV/bin/python --no-cache --no-install-project --no-dev && \ rm -rf /tmp/build WORKDIR / @@ -34,7 +34,7 @@ WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- RUN uv pip install --python $VIRTUAL_ENV/bin/python --no-cache build && \ cd /tmp && \ - uv pip download --python $VIRTUAL_ENV/bin/python --no-binary numpy --no-deps numpy && \ + $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \ tar -xzf numpy-*.tar.gz && \ cd numpy-*/ && \ $VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ From 29c0a6e524d51c4b356c5ad05e2b5767ef27d72a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 17:02:02 -0600 Subject: [PATCH 25/63] fix: update dependencies in pyproject.toml for compatibility --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a8b5cff..bdd056cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "requests==2.32.5", "psutil==7.1.3", "pillow", - "drf-yasg>=1.21.11", + "drf-spectacular>=0.29.0", "streamlink", "python-vlc", "yt-dlp", @@ -26,6 +26,7 @@ dependencies = [ "rapidfuzz==3.14.3", "regex", "tzlocal", + "pytz", "torch==2.9.1+cpu", "sentence-transformers==5.2.0", "channels", @@ -33,7 +34,6 @@ dependencies = [ "django-filter", "django-celery-beat", "lxml==6.0.2", - "gunicorn", ] [build-system] From 71e117a85021bf9b8ab0cfb089997ac3ace3caf0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 29 Jan 2026 17:24:41 -0600 Subject: [PATCH 26/63] Remove uv.lock and add to gitignore. This gets generated automatically during build anyway and would add confusion to users not using our container. --- .gitignore | 3 +- uv.lock | 2098 ---------------------------------------------------- 2 files changed, 2 insertions(+), 2099 deletions(-) delete mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 20968f46..6d1becaa 100755 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ debugpy* uwsgi.sock package-lock.json models -.idea \ No newline at end of file +.idea +uv.lock \ No newline at end of file diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 8361f8fc..00000000 --- a/uv.lock +++ /dev/null @@ -1,2098 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.13" - -[[package]] -name = "amqp" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, -] - -[[package]] -name = "asgiref" -version = "3.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "autobahn" -version = "25.12.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cbor2" }, - { name = "cffi" }, - { name = "cryptography" }, - { name = "hyperlink" }, - { name = "msgpack", marker = "platform_python_implementation == 'CPython'" }, - { name = "py-ubjson" }, - { name = "txaio" }, - { name = "u-msgpack-python", marker = "platform_python_implementation != 'CPython'" }, - { name = "ujson" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/54/d5/9adf0f5b9eb244e58e898e9f3db4b00c09835ef4b6c37d491886e0376b4f/autobahn-25.12.2.tar.gz", hash = "sha256:754c06a54753aeb7e8d10c5cbf03249ad9e2a1a32bca8be02865c6f00628a98c", size = 13893652, upload-time = "2025-12-15T11:13:19.086Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/30/ef9c47038e4e9257319d6e1b87668b3df360a0c488d66ccff9d11aaff6ba/autobahn-25.12.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:bc17f6cab9438156d2701c293c76fd02a144f9be0a992c065dfee1935ce4845b", size = 1960447, upload-time = "2025-12-15T11:13:05.007Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e4/f3d5cb70bc0b9b5523d940734b2e0a251510d051a50d2e723f321e890859/autobahn-25.12.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5297a782fc7d0a26842438ef1342549ceee29496cda52672ac44635c79eeb94", size = 2053955, upload-time = "2025-12-15T11:13:06.052Z" }, - { url = "https://files.pythonhosted.org/packages/ea/49/4e592a19ae58fd9c796821a882b22598fac295ede50f899cc9d14a0282b6/autobahn-25.12.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0c3f1d5dafda52f8dc962ab583b6f3473b7b7186cab082d05372ed43a8261a5", size = 2225441, upload-time = "2025-12-15T11:13:07.527Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f7/430074a5ea3f6187335a4ddc26f16dd75d5125e346a84cf132ddbd41a3e8/autobahn-25.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:e9e2a962f2de0bc4c53b452916458417a15f5137c956245ac6d0a783a83fa1f7", size = 2151873, upload-time = "2025-12-15T11:13:08.89Z" }, - { url = "https://files.pythonhosted.org/packages/54/b7/0a0e3ecb2af7e452f5f359d19bdc647cbc8658f3f498bfa3bf8545cf4768/autobahn-25.12.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c840ee136bfaf6560467160129b0b25a0e33c9a51e2b251e98c5474f27583915", size = 1960463, upload-time = "2025-12-15T11:13:10.183Z" }, - { url = "https://files.pythonhosted.org/packages/19/8b/4215ac49d6b793b592fb08698f3a0e21a59eb3520be7f7ed288fcb52d919/autobahn-25.12.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9abda5cf817c0f8a19a55a67a031adf2fc70ed351719b5bd9e6fa0f5f4bc8f89", size = 2225590, upload-time = "2025-12-15T11:13:11.367Z" }, - { url = "https://files.pythonhosted.org/packages/f6/58/e498821606db57305c8f3c26d9b28fd73e4e0583a1f48330df500721c418/autobahn-25.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:18b12e8af7fc115487715afa10b3f5b5a4b5989bebbe05b71722cf9fce7b1bfb", size = 2184111, upload-time = "2025-12-15T11:13:12.461Z" }, -] - -[[package]] -name = "automat" -version = "25.4.16" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977, upload-time = "2025-04-16T20:12:16.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842, upload-time = "2025-04-16T20:12:14.447Z" }, -] - -[[package]] -name = "billiard" -version = "4.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, -] - -[[package]] -name = "cbor2" -version = "5.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" }, - { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d2/d41f8c04c783a4d204e364be2d38043d4f732a3bed6f4c732e321cf34c7b/cbor2-5.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c114af8099fa65a19a514db87ce7a06e942d8fea2730afd49be39f8e16e7f5e0", size = 69841, upload-time = "2025-12-30T18:44:01.159Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8c/0397a82f6e67665009951453c83058e4c77ba54b9a9017ede56d6870306c/cbor2-5.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:ab3ba00494ad8669a459b12a558448d309c271fa4f89b116ad496ee35db38fea", size = 64982, upload-time = "2025-12-30T18:44:02.138Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" }, - { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" }, - { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" }, - { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" }, - { url = "https://files.pythonhosted.org/packages/88/6d/e43452347630efe8133f5304127539100d937c138c0996d27ec63963ec2c/cbor2-5.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:b51c5e59becae746ca4de2bbaa8a2f5c64a68fec05cea62941b1a84a8335f7d1", size = 71657, upload-time = "2025-12-30T18:44:10.162Z" }, - { url = "https://files.pythonhosted.org/packages/8b/66/9a780ef34ab10a0437666232e885378cdd5f60197b1b5e61a62499e5a10a/cbor2-5.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:53b630f4db4b9f477ad84077283dd17ecf9894738aa17ef4938c369958e02a71", size = 67171, upload-time = "2025-12-30T18:44:11.619Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" }, -] - -[[package]] -name = "celery" -version = "5.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "exceptiongroup" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "tzlocal" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/5f/b681ae3c89290d2ea6562ea96b40f5af6f6fc5f7743e2cd1a19e47721548/celery-5.6.0.tar.gz", hash = "sha256:641405206042d52ae460e4e9751a2e31b06cf80ab836fcf92e0b9311d7ea8113", size = 1712522, upload-time = "2025-11-30T17:39:46.282Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/4e/53a125038d6a814491a0ae3457435c13cf8821eb602292cf9db37ce35f62/celery-5.6.0-py3-none-any.whl", hash = "sha256:33cf01477b175017fc8f22c5ee8a65157591043ba8ca78a443fe703aa910f581", size = 444561, upload-time = "2025-11-30T17:39:44.314Z" }, -] - -[package.optional-dependencies] -redis = [ - { name = "kombu", extra = ["redis"] }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "channels" -version = "4.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667", size = 27023, upload-time = "2025-11-20T15:13:05.102Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176", size = 31313, upload-time = "2025-11-20T15:13:02.357Z" }, -] - -[[package]] -name = "channels-redis" -version = "4.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "channels" }, - { name = "msgpack" }, - { name = "redis" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2", size = 31440, upload-time = "2025-07-22T13:48:46.087Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5", size = 20641, upload-time = "2025-07-22T13:48:44.545Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, -] - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, -] - -[[package]] -name = "click-repl" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "constantly" -version = "23.10.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/6f/cb2a94494ff74aa9528a36c5b1422756330a75a8367bf20bd63171fc324d/constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd", size = 13300, upload-time = "2023-10-28T23:18:24.316Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/40/c199d095151addf69efdb4b9ca3a4f20f70e20508d6222bffb9b76f58573/constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9", size = 13547, upload-time = "2023-10-28T23:18:23.038Z" }, -] - -[[package]] -name = "cron-descriptor" -version = "2.0.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7c/31/0b21d1599656b2ffa6043e51ca01041cd1c0f6dacf5a3e2b620ed120e7d8/cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157", size = 49456, upload-time = "2025-09-03T16:30:22.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/cc/361326a54ad92e2e12845ad15e335a4e14b8953665007fb514d3393dfb0f/cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6", size = 74446, upload-time = "2025-09-03T16:30:21.397Z" }, -] - -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, -] - -[[package]] -name = "daphne" -version = "4.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "autobahn" }, - { name = "twisted", extra = ["tls"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/9d/322b605fdc03b963cf2d33943321c8f4405e8d82e698bf49d1eed1ca40c4/daphne-4.2.1.tar.gz", hash = "sha256:5f898e700a1fda7addf1541d7c328606415e96a7bd768405f0463c312fcb31b3", size = 45600, upload-time = "2025-07-02T12:57:04.935Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/34/6171ab34715ed210bcd6c2b38839cc792993cff4fe2493f50bc92b0086a0/daphne-4.2.1-py3-none-any.whl", hash = "sha256:881e96b387b95b35ad85acd855f229d7f5b79073d6649089c8a33f661885e055", size = 29015, upload-time = "2025-07-02T12:57:03.793Z" }, -] - -[[package]] -name = "dispatcharr" -version = "0.17.0" -source = { editable = "." } -dependencies = [ - { name = "celery", extra = ["redis"] }, - { name = "channels" }, - { name = "channels-redis" }, - { name = "daphne" }, - { name = "django" }, - { name = "django-celery-beat" }, - { name = "django-cors-headers" }, - { name = "django-filter" }, - { name = "djangorestframework" }, - { name = "djangorestframework-simplejwt" }, - { name = "drf-yasg" }, - { name = "gevent" }, - { name = "gunicorn" }, - { name = "lxml" }, - { name = "m3u8" }, - { name = "pillow" }, - { name = "psutil" }, - { name = "psycopg2-binary" }, - { name = "python-vlc" }, - { name = "rapidfuzz" }, - { name = "regex" }, - { name = "requests" }, - { name = "sentence-transformers" }, - { name = "streamlink" }, - { name = "torch" }, - { name = "tzlocal" }, - { name = "uwsgi" }, - { name = "yt-dlp" }, -] - -[package.metadata] -requires-dist = [ - { name = "celery", extras = ["redis"], specifier = "==5.6.0" }, - { name = "channels" }, - { name = "channels-redis", specifier = "==4.3.0" }, - { name = "daphne" }, - { name = "django", specifier = "==5.2.9" }, - { name = "django-celery-beat" }, - { name = "django-cors-headers" }, - { name = "django-filter" }, - { name = "djangorestframework", specifier = "==3.16.1" }, - { name = "djangorestframework-simplejwt" }, - { name = "drf-yasg", specifier = ">=1.21.11" }, - { name = "gevent", specifier = "==25.9.1" }, - { name = "gunicorn" }, - { name = "lxml", specifier = "==6.0.2" }, - { name = "m3u8" }, - { name = "pillow" }, - { name = "psutil", specifier = "==7.1.3" }, - { name = "psycopg2-binary", specifier = "==2.9.11" }, - { name = "python-vlc" }, - { name = "rapidfuzz", specifier = "==3.14.3" }, - { name = "regex" }, - { name = "requests", specifier = "==2.32.5" }, - { name = "sentence-transformers", specifier = "==5.2.0" }, - { name = "streamlink" }, - { name = "torch", specifier = "==2.9.1+cpu", index = "https://download.pytorch.org/whl/cpu" }, - { name = "tzlocal" }, - { name = "uwsgi" }, - { name = "yt-dlp" }, -] - -[[package]] -name = "django" -version = "5.2.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, -] - -[[package]] -name = "django-celery-beat" -version = "2.8.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "celery" }, - { name = "cron-descriptor" }, - { name = "django" }, - { name = "django-timezone-field" }, - { name = "python-crontab" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/aa/11/0c8b412869b4fda72828572068312b10aafe7ccef7b41af3633af31f9d4b/django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a", size = 175802, upload-time = "2025-05-13T06:58:29.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/e5/3a0167044773dee989b498e9a851fc1663bea9ab879f1179f7b8a827ac10/django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171", size = 104833, upload-time = "2025-05-13T06:58:27.309Z" }, -] - -[[package]] -name = "django-cors-headers" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, -] - -[[package]] -name = "django-filter" -version = "25.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, -] - -[[package]] -name = "django-timezone-field" -version = "7.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" }, -] - -[[package]] -name = "djangorestframework" -version = "3.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, -] - -[[package]] -name = "djangorestframework-simplejwt" -version = "5.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, - { name = "djangorestframework" }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, -] - -[[package]] -name = "drf-yasg" -version = "1.21.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, - { name = "djangorestframework" }, - { name = "inflection" }, - { name = "packaging" }, - { name = "pytz" }, - { name = "pyyaml" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/1b/2305804c1efbc2da1890fb2a34155ef298565eb152e1c00bd35546be42fa/drf_yasg-1.21.14.tar.gz", hash = "sha256:68d62d5f7505611ba9577115c83b36211cc20d9748a50ce95a44abe411727d10", size = 5153307, upload-time = "2026-01-15T11:37:07.086Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/de/61880040534036044572225c485b0a4adc99b4c92e3eed3e5741b31674fd/drf_yasg-1.21.14-py3-none-any.whl", hash = "sha256:725ddda28aec7efc4cab985290fe790c8565e665017f7c80211d288a35821c70", size = 4856031, upload-time = "2026-01-15T11:37:04.753Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - -[[package]] -name = "fsspec" -version = "2026.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, -] - -[[package]] -name = "gevent" -version = "25.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, - { name = "zope-event" }, - { name = "zope-interface" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, - { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, - { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, - { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, - { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, - { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, - { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" }, - { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" }, - { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" }, - { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" }, - { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" }, - { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" }, -] - -[[package]] -name = "greenlet" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, - { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, - { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, - { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, - { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, - { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, - { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, -] - -[[package]] -name = "gunicorn" -version = "24.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/8b/3cd14f3d78f050f8c703cb1a881d62f5fc695cfd9f258386982bdc1a018a/gunicorn-24.1.0.tar.gz", hash = "sha256:6df323c06bcb9499cbf8bc3b89a56619ac54d6a8c66130681e206272cc5b8e73", size = 286767, upload-time = "2026-01-23T20:50:38.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ea/f91518dd05d81eb8fb4c10c86802aa943d65e4df4dbc820ec58e2597b501/gunicorn-24.1.0-py3-none-any.whl", hash = "sha256:d7c3e50dfdade301b1a94ed3e9a2c07ab7d154908203813c10d7b175d04bbf23", size = 114495, upload-time = "2026-01-23T20:50:36.855Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "hf-xet" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, - { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, - { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, -] - -[[package]] -name = "huggingface-hub" -version = "0.36.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/63/4910c5fa9128fdadf6a9c5ac138e8b1b6cee4ca44bf7915bbfbce4e355ee/huggingface_hub-0.36.0.tar.gz", hash = "sha256:47b3f0e2539c39bf5cde015d63b72ec49baff67b6931c3d97f3f84532e2b8d25", size = 463358, upload-time = "2025-10-23T12:12:01.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, -] - -[[package]] -name = "hyperlink" -version = "21.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743, upload-time = "2021-01-08T05:51:20.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638, upload-time = "2021-01-08T05:51:22.906Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "incremental" -version = "24.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/3c/82e84109e02c492f382c711c58a3dd91badda6d746def81a1465f74dc9f5/incremental-24.11.0.tar.gz", hash = "sha256:87d3480dbb083c1d736222511a8cf380012a8176c2456d01ef483242abbbcf8c", size = 24000, upload-time = "2025-11-28T02:30:17.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/55/0f4df2a44053867ea9cbea73fc588b03c55605cd695cee0a3d86f0029cb2/incremental-24.11.0-py3-none-any.whl", hash = "sha256:a34450716b1c4341fe6676a0598e88a39e04189f4dce5dc96f656e040baa10b3", size = 21109, upload-time = "2025-11-28T02:30:16.442Z" }, -] - -[[package]] -name = "inflection" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, -] - -[[package]] -name = "isodate" -version = "0.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - -[[package]] -name = "kombu" -version = "5.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "amqp" }, - { name = "packaging" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, -] - -[package.optional-dependencies] -redis = [ - { name = "redis" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, -] - -[[package]] -name = "m3u8" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720, upload-time = "2024-08-07T11:20:06.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133, upload-time = "2024-08-07T11:20:03.96Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - -[[package]] -name = "msgpack" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, -] - -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - -[[package]] -name = "numpy" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, - { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, - { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, - { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, - { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, - { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, - { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, - { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, - { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, - { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, - { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, - { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, - { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, - { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, - { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, - { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, - { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, - { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, - { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, - { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, - { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, - { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, -] - -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "pillow" -version = "12.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "psutil" -version = "7.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, - { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, - { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, - { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, - { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, - { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, - { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, - { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, - { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, - { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, - { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, -] - -[[package]] -name = "psycopg2-binary" -version = "2.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, - { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, - { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, - { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, - { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, -] - -[[package]] -name = "py-ubjson" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/c7/28220d37e041fe1df03e857fe48f768dcd30cd151480bf6f00da8713214a/py-ubjson-0.16.1.tar.gz", hash = "sha256:b9bfb8695a1c7e3632e800fb83c943bf67ed45ddd87cd0344851610c69a5a482", size = 50316, upload-time = "2020-04-18T15:05:57.698Z" } - -[[package]] -name = "pyasn1" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - -[[package]] -name = "pycountry" -version = "24.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pycryptodome" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, - { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, - { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, - { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, - { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, - { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, - { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, - { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, - { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, - { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, - { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, -] - -[[package]] -name = "pyopenssl" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, -] - -[[package]] -name = "pysocks" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, -] - -[[package]] -name = "python-crontab" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-vlc" -version = "3.0.21203" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/5b/f9ce6f0c9877b6fe5eafbade55e0dcb6b2b30f1c2c95837aef40e390d63b/python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec", size = 162211, upload-time = "2024-10-07T14:39:54.755Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/ee/7d76eb3b50ccb1397621f32ede0fb4d17aa55a9aa2251bc34e6b9929fdce/python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320", size = 87651, upload-time = "2024-10-07T14:39:50.021Z" }, -] - -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "rapidfuzz" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" }, - { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" }, - { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" }, - { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" }, - { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" }, - { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" }, - { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" }, - { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" }, - { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" }, - { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" }, - { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" }, - { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" }, - { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" }, -] - -[[package]] -name = "redis" -version = "6.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, -] - -[[package]] -name = "regex" -version = "2026.1.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, - { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, - { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, - { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, - { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, - { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, - { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, - { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, - { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, - { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, - { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, - { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, - { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, - { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, - { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, - { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, - { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, - { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, - { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, - { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, - { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, - { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, - { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, - { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, - { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, - { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, - { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, - { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, - { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "safetensors" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, - { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, - { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, - { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, - { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, - { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, - { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, - { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, -] - -[[package]] -name = "scikit-learn" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, - { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, - { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, -] - -[[package]] -name = "scipy" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/51/3468fdfd49387ddefee1636f5cf6d03ce603b75205bf439bbf0e62069bfd/scipy-1.17.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:65ec32f3d32dfc48c72df4291345dae4f048749bc8d5203ee0a3f347f96c5ce6", size = 31344101, upload-time = "2026-01-10T21:26:30.25Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/9406aec58268d437636069419e6977af953d1e246df941d42d3720b7277b/scipy-1.17.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:1f9586a58039d7229ce77b52f8472c972448cded5736eaf102d5658bbac4c269", size = 27950385, upload-time = "2026-01-10T21:26:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/4f/98/e7342709e17afdfd1b26b56ae499ef4939b45a23a00e471dfb5375eea205/scipy-1.17.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9fad7d3578c877d606b1150135c2639e9de9cecd3705caa37b66862977cc3e72", size = 20122115, upload-time = "2026-01-10T21:26:42.107Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/9eeeb5357a64fd157cbe0302c213517c541cc16b8486d82de251f3c68ede/scipy-1.17.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:423ca1f6584fc03936972b5f7c06961670dbba9f234e71676a7c7ccf938a0d61", size = 22442402, upload-time = "2026-01-10T21:26:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/be13397a0e434f98e0c79552b2b584ae5bb1c8b2be95db421533bbca5369/scipy-1.17.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe508b5690e9eaaa9467fc047f833af58f1152ae51a0d0aed67aa5801f4dd7d6", size = 32696338, upload-time = "2026-01-10T21:26:55.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/1e/12fbf2a3bb240161651c94bb5cdd0eae5d4e8cc6eaeceb74ab07b12a753d/scipy-1.17.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6680f2dfd4f6182e7d6db161344537da644d1cf85cf293f015c60a17ecf08752", size = 34977201, upload-time = "2026-01-10T21:27:03.501Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/1a63923e23ccd20bd32156d7dd708af5bbde410daa993aa2500c847ab2d2/scipy-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eec3842ec9ac9de5917899b277428886042a93db0b227ebbe3a333b64ec7643d", size = 34777384, upload-time = "2026-01-10T21:27:11.423Z" }, - { url = "https://files.pythonhosted.org/packages/39/22/b5da95d74edcf81e540e467202a988c50fef41bd2011f46e05f72ba07df6/scipy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d7425fcafbc09a03731e1bc05581f5fad988e48c6a861f441b7ab729a49a55ea", size = 37379586, upload-time = "2026-01-10T21:27:20.171Z" }, - { url = "https://files.pythonhosted.org/packages/b9/b6/8ac583d6da79e7b9e520579f03007cb006f063642afd6b2eeb16b890bf93/scipy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:87b411e42b425b84777718cc41516b8a7e0795abfa8e8e1d573bf0ef014f0812", size = 36287211, upload-time = "2026-01-10T21:28:43.122Z" }, - { url = "https://files.pythonhosted.org/packages/55/fb/7db19e0b3e52f882b420417644ec81dd57eeef1bd1705b6f689d8ff93541/scipy-1.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:357ca001c6e37601066092e7c89cca2f1ce74e2a520ca78d063a6d2201101df2", size = 24312646, upload-time = "2026-01-10T21:28:49.893Z" }, - { url = "https://files.pythonhosted.org/packages/20/b6/7feaa252c21cc7aff335c6c55e1b90ab3e3306da3f048109b8b639b94648/scipy-1.17.0-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:ec0827aa4d36cb79ff1b81de898e948a51ac0b9b1c43e4a372c0508c38c0f9a3", size = 31693194, upload-time = "2026-01-10T21:27:27.454Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/bbb392005abce039fb7e672cb78ac7d158700e826b0515cab6b5b60c26fb/scipy-1.17.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:819fc26862b4b3c73a60d486dbb919202f3d6d98c87cf20c223511429f2d1a97", size = 28365415, upload-time = "2026-01-10T21:27:34.26Z" }, - { url = "https://files.pythonhosted.org/packages/37/da/9d33196ecc99fba16a409c691ed464a3a283ac454a34a13a3a57c0d66f3a/scipy-1.17.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:363ad4ae2853d88ebcde3ae6ec46ccca903ea9835ee8ba543f12f575e7b07e4e", size = 20537232, upload-time = "2026-01-10T21:27:40.306Z" }, - { url = "https://files.pythonhosted.org/packages/56/9d/f4b184f6ddb28e9a5caea36a6f98e8ecd2a524f9127354087ce780885d83/scipy-1.17.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:979c3a0ff8e5ba254d45d59ebd38cde48fce4f10b5125c680c7a4bfe177aab07", size = 22791051, upload-time = "2026-01-10T21:27:46.539Z" }, - { url = "https://files.pythonhosted.org/packages/9b/9d/025cccdd738a72140efc582b1641d0dd4caf2e86c3fb127568dc80444e6e/scipy-1.17.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:130d12926ae34399d157de777472bf82e9061c60cc081372b3118edacafe1d00", size = 32815098, upload-time = "2026-01-10T21:27:54.389Z" }, - { url = "https://files.pythonhosted.org/packages/48/5f/09b879619f8bca15ce392bfc1894bd9c54377e01d1b3f2f3b595a1b4d945/scipy-1.17.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e886000eb4919eae3a44f035e63f0fd8b651234117e8f6f29bad1cd26e7bc45", size = 35031342, upload-time = "2026-01-10T21:28:03.012Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9a/f0f0a9f0aa079d2f106555b984ff0fbb11a837df280f04f71f056ea9c6e4/scipy-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13c4096ac6bc31d706018f06a49abe0485f96499deb82066b94d19b02f664209", size = 34893199, upload-time = "2026-01-10T21:28:10.832Z" }, - { url = "https://files.pythonhosted.org/packages/90/b8/4f0f5cf0c5ea4d7548424e6533e6b17d164f34a6e2fb2e43ffebb6697b06/scipy-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cacbaddd91fcffde703934897c5cd2c7cb0371fac195d383f4e1f1c5d3f3bd04", size = 37438061, upload-time = "2026-01-10T21:28:19.684Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cc/2bd59140ed3b2fa2882fb15da0a9cb1b5a6443d67cfd0d98d4cec83a57ec/scipy-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:edce1a1cf66298cccdc48a1bdf8fb10a3bf58e8b58d6c3883dd1530e103f87c0", size = 36328593, upload-time = "2026-01-10T21:28:28.007Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/c87cc44a0d2c7aaf0f003aef2904c3d097b422a96c7e7c07f5efd9073c1b/scipy-1.17.0-cp313-cp313t-win_arm64.whl", hash = "sha256:30509da9dbec1c2ed8f168b8d8aa853bc6723fede1dbc23c7d43a56f5ab72a67", size = 24625083, upload-time = "2026-01-10T21:28:35.188Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, - { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, - { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, - { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, - { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, - { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, - { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, - { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, - { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, - { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, -] - -[[package]] -name = "sentence-transformers" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, - { name = "scikit-learn" }, - { name = "scipy" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "transformers" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/a1/64e7b111e753307ffb7c5b6d039c52d4a91a47fa32a7f5bc377a49b22402/sentence_transformers-5.2.0.tar.gz", hash = "sha256:acaeb38717de689f3dab45d5e5a02ebe2f75960a4764ea35fea65f58a4d3019f", size = 381004, upload-time = "2025-12-11T14:12:31.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/d0/3b2897ef6a0c0c801e9fecca26bcc77081648e38e8c772885ebdd8d7d252/sentence_transformers-5.2.0-py3-none-any.whl", hash = "sha256:aa57180f053687d29b08206766ae7db549be5074f61849def7b17bf0b8025ca2", size = 493748, upload-time = "2025-12-11T14:12:29.516Z" }, -] - -[[package]] -name = "service-identity" -version = "24.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cryptography" }, - { name = "pyasn1" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" }, -] - -[[package]] -name = "setuptools" -version = "80.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "sqlparse" -version = "0.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, -] - -[[package]] -name = "streamlink" -version = "8.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "isodate" }, - { name = "lxml" }, - { name = "pycountry" }, - { name = "pycryptodome" }, - { name = "pysocks" }, - { name = "requests" }, - { name = "trio" }, - { name = "trio-websocket" }, - { name = "urllib3" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7a/89/ea3baa9d0c2d564412be15ef5e799a56bf2a0068b27197e5e4c312ba13f5/streamlink-8.1.2.tar.gz", hash = "sha256:d08099fa1b169bad4a991d31b2ab89f04ba08b1319ed1f5bd0ead8547d4c5ad3", size = 823590, upload-time = "2026-01-18T12:21:53.28Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/8f/124e4db6bbf8fe3ab9bfe4dbd029f29211629c0f1733da2695576917c226/streamlink-8.1.2-py3-none-any.whl", hash = "sha256:2355cffcdbe60e8270144b5191a20b2ab195bbb9150b453334a23b829d1d75c7", size = 556372, upload-time = "2026-01-18T12:21:47.062Z" }, - { url = "https://files.pythonhosted.org/packages/c9/11/63a8c6b2b733d55189888504530865f68a3b69ebde7b78138d680e56bb45/streamlink-8.1.2-py3-none-win32.whl", hash = "sha256:c5aba06f24bb7a6a261ec409a301b5d8564ea865933f08001533067e5e40529c", size = 556386, upload-time = "2026-01-18T12:21:49.505Z" }, - { url = "https://files.pythonhosted.org/packages/ed/5a/81a8bbe0d8755154979a4cd56f2b03da67ddd2db7ab9e14145a35cf0eb72/streamlink-8.1.2-py3-none-win_amd64.whl", hash = "sha256:86043d339a1b9b9b49f36ed26cdd421d226a021f06c9434e2177bfc6caf420d5", size = 556389, upload-time = "2026-01-18T12:21:51.385Z" }, -] - -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, -] - -[[package]] -name = "tokenizers" -version = "0.22.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, -] - -[[package]] -name = "torch" -version = "2.9.1+cpu" -source = { registry = "https://download.pytorch.org/whl/cpu" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:3e532e553b37ee859205a9b2d1c7977fd6922f53bbb1b9bfdd5bdc00d1a60ed4" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:39b3dff6d8fba240ae0d1bede4ca11c2531ae3b47329206512d99e17907ff74b" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:404a7ab2fffaf2ca069e662f331eb46313692b2f1630df2720094284f390ccef" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:161decbff26a33f13cb5ba6d2c8f458bbf56193bcc32ecc70be6dd4c7a3ee79d" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:01b1884f724977a20c7da2f640f1c7b37f4a2c117a7f4a6c1c0424d14cb86322" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:031a597147fa81b1e6d79ccf1ad3ccc7fafa27941d6cf26ff5caaa384fb20e92" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:e586ab1363e3f86aa4cc133b7fdcf98deb1d2c13d43a7a6e5a6a18e9c5364893" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:65010ab4aacce6c9a1ddfc935f986c003ca8638ded04348fd326c3e74346237c" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:88adf5157db5da1d54b1c9fe4a6c1d20ceef00e75d854e206a87dbf69e3037dc" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:f60e2565f261542efac07e25208fb3fc55c6fe82314a5a9cbee971edb5f27713" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3ac2b8df2c55430e836dcda31940d47f1f5f94b8731057b6f20300ebea394dd9" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5b688445f928f13563b7418b17c57e97bf955ab559cf73cd8f2b961f8572dbb3" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.9.1%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:cf9c3e50b595721ca6b488bdcc326e0f1af73ed28b9b66eff504a96649bb5c96" }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, -] - -[[package]] -name = "transformers" -version = "4.57.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "huggingface-hub" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "requests" }, - { name = "safetensors" }, - { name = "tokenizers" }, - { name = "tqdm" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/35/67252acc1b929dc88b6602e8c4a982e64f31e733b804c14bc24b47da35e6/transformers-4.57.6.tar.gz", hash = "sha256:55e44126ece9dc0a291521b7e5492b572e6ef2766338a610b9ab5afbb70689d3", size = 10134912, upload-time = "2026-01-16T10:38:39.284Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl", hash = "sha256:4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550", size = 11993498, upload-time = "2026-01-16T10:38:31.289Z" }, -] - -[[package]] -name = "trio" -version = "0.32.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, -] - -[[package]] -name = "trio-websocket" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "outcome" }, - { name = "trio" }, - { name = "wsproto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, -] - -[[package]] -name = "twisted" -version = "25.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "automat" }, - { name = "constantly" }, - { name = "hyperlink" }, - { name = "incremental" }, - { name = "typing-extensions" }, - { name = "zope-interface" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/0f/82716ed849bf7ea4984c21385597c949944f0f9b428b5710f79d0afc084d/twisted-25.5.0.tar.gz", hash = "sha256:1deb272358cb6be1e3e8fc6f9c8b36f78eb0fa7c2233d2dbe11ec6fee04ea316", size = 3545725, upload-time = "2025-06-07T09:52:24.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/66/ab7efd8941f0bc7b2bd555b0f0471bff77df4c88e0cc31120c82737fec77/twisted-25.5.0-py3-none-any.whl", hash = "sha256:8559f654d01a54a8c3efe66d533d43f383531ebf8d81d9f9ab4769d91ca15df7", size = 3204767, upload-time = "2025-06-07T09:52:21.428Z" }, -] - -[package.optional-dependencies] -tls = [ - { name = "idna" }, - { name = "pyopenssl" }, - { name = "service-identity" }, -] - -[[package]] -name = "txaio" -version = "25.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/67/ea9c9ddbaa3e0b4d53c91f8778a33e42045be352dc7200ed2b9aaa7dc229/txaio-25.12.2.tar.gz", hash = "sha256:9f232c21e12aa1ff52690e365b5a0ecfd42cc27a6ec86e1b92ece88f763f4b78", size = 117393, upload-time = "2025-12-09T15:03:26.527Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/05/bdb6318120cac9bf97779674f49035e0595d894b42d4c43b60637bafdb1f/txaio-25.12.2-py3-none-any.whl", hash = "sha256:5f6cd6c6b397fc3305790d15efd46a2d5b91cdbefa96543b4f8666aeb56ba026", size = 31208, upload-time = "2025-12-09T04:30:27.811Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - -[[package]] -name = "u-msgpack-python" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/9d/a40411a475e7d4838994b7f6bcc6bfca9acc5b119ce3a7503608c4428b49/u-msgpack-python-2.8.0.tar.gz", hash = "sha256:b801a83d6ed75e6df41e44518b4f2a9c221dc2da4bcd5380e3a0feda520bc61a", size = 18167, upload-time = "2023-05-18T09:28:12.187Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/5e/512aeb40fd819f4660d00f96f5c7371ee36fc8c6b605128c5ee59e0b28c6/u_msgpack_python-2.8.0-py2.py3-none-any.whl", hash = "sha256:1d853d33e78b72c4228a2025b4db28cda81214076e5b0422ed0ae1b1b2bb586a", size = 10590, upload-time = "2023-05-18T09:28:10.323Z" }, -] - -[[package]] -name = "ujson" -version = "5.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/d9/3f17e3c5773fb4941c68d9a37a47b1a79c9649d6c56aefbed87cc409d18a/ujson-5.11.0.tar.gz", hash = "sha256:e204ae6f909f099ba6b6b942131cee359ddda2b6e4ea39c12eb8b991fe2010e0", size = 7156583, upload-time = "2025-08-20T11:57:02.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/ec/2de9dd371d52c377abc05d2b725645326c4562fc87296a8907c7bcdf2db7/ujson-5.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:109f59885041b14ee9569bf0bb3f98579c3fa0652317b355669939e5fc5ede53", size = 55435, upload-time = "2025-08-20T11:55:50.243Z" }, - { url = "https://files.pythonhosted.org/packages/5b/a4/f611f816eac3a581d8a4372f6967c3ed41eddbae4008d1d77f223f1a4e0a/ujson-5.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a31c6b8004438e8c20fc55ac1c0e07dad42941db24176fe9acf2815971f8e752", size = 53193, upload-time = "2025-08-20T11:55:51.373Z" }, - { url = "https://files.pythonhosted.org/packages/e9/c5/c161940967184de96f5cbbbcce45b562a4bf851d60f4c677704b1770136d/ujson-5.11.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78c684fb21255b9b90320ba7e199780f653e03f6c2528663768965f4126a5b50", size = 57603, upload-time = "2025-08-20T11:55:52.583Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d6/c7b2444238f5b2e2d0e3dab300b9ddc3606e4b1f0e4bed5a48157cebc792/ujson-5.11.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:4c9f5d6a27d035dd90a146f7761c2272cf7103de5127c9ab9c4cd39ea61e878a", size = 59794, upload-time = "2025-08-20T11:55:53.69Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a3/292551f936d3d02d9af148f53e1bc04306b00a7cf1fcbb86fa0d1c887242/ujson-5.11.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:837da4d27fed5fdc1b630bd18f519744b23a0b5ada1bbde1a36ba463f2900c03", size = 57363, upload-time = "2025-08-20T11:55:54.843Z" }, - { url = "https://files.pythonhosted.org/packages/90/a6/82cfa70448831b1a9e73f882225980b5c689bf539ec6400b31656a60ea46/ujson-5.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:787aff4a84da301b7f3bac09bc696e2e5670df829c6f8ecf39916b4e7e24e701", size = 1036311, upload-time = "2025-08-20T11:55:56.197Z" }, - { url = "https://files.pythonhosted.org/packages/84/5c/96e2266be50f21e9b27acaee8ca8f23ea0b85cb998c33d4f53147687839b/ujson-5.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6dd703c3e86dc6f7044c5ac0b3ae079ed96bf297974598116aa5fb7f655c3a60", size = 1195783, upload-time = "2025-08-20T11:55:58.081Z" }, - { url = "https://files.pythonhosted.org/packages/8d/20/78abe3d808cf3bb3e76f71fca46cd208317bf461c905d79f0d26b9df20f1/ujson-5.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3772e4fe6b0c1e025ba3c50841a0ca4786825a4894c8411bf8d3afe3a8061328", size = 1088822, upload-time = "2025-08-20T11:55:59.469Z" }, - { url = "https://files.pythonhosted.org/packages/d8/50/8856e24bec5e2fc7f775d867aeb7a3f137359356200ac44658f1f2c834b2/ujson-5.11.0-cp313-cp313-win32.whl", hash = "sha256:8fa2af7c1459204b7a42e98263b069bd535ea0cd978b4d6982f35af5a04a4241", size = 39753, upload-time = "2025-08-20T11:56:01.345Z" }, - { url = "https://files.pythonhosted.org/packages/5b/d8/1baee0f4179a4d0f5ce086832147b6cc9b7731c24ca08e14a3fdb8d39c32/ujson-5.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:34032aeca4510a7c7102bd5933f59a37f63891f30a0706fb46487ab6f0edf8f0", size = 43866, upload-time = "2025-08-20T11:56:02.552Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8c/6d85ef5be82c6d66adced3ec5ef23353ed710a11f70b0b6a836878396334/ujson-5.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:ce076f2df2e1aa62b685086fbad67f2b1d3048369664b4cdccc50707325401f9", size = 38363, upload-time = "2025-08-20T11:56:03.688Z" }, - { url = "https://files.pythonhosted.org/packages/28/08/4518146f4984d112764b1dfa6fb7bad691c44a401adadaa5e23ccd930053/ujson-5.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65724738c73645db88f70ba1f2e6fb678f913281804d5da2fd02c8c5839af302", size = 55462, upload-time = "2025-08-20T11:56:04.873Z" }, - { url = "https://files.pythonhosted.org/packages/29/37/2107b9a62168867a692654d8766b81bd2fd1e1ba13e2ec90555861e02b0c/ujson-5.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29113c003ca33ab71b1b480bde952fbab2a0b6b03a4ee4c3d71687cdcbd1a29d", size = 53246, upload-time = "2025-08-20T11:56:06.054Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f8/25583c70f83788edbe3ca62ce6c1b79eff465d78dec5eb2b2b56b3e98b33/ujson-5.11.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c44c703842024d796b4c78542a6fcd5c3cb948b9fc2a73ee65b9c86a22ee3638", size = 57631, upload-time = "2025-08-20T11:56:07.374Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ca/19b3a632933a09d696f10dc1b0dfa1d692e65ad507d12340116ce4f67967/ujson-5.11.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e750c436fb90edf85585f5c62a35b35082502383840962c6983403d1bd96a02c", size = 59877, upload-time = "2025-08-20T11:56:08.534Z" }, - { url = "https://files.pythonhosted.org/packages/55/7a/4572af5324ad4b2bfdd2321e898a527050290147b4ea337a79a0e4e87ec7/ujson-5.11.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f278b31a7c52eb0947b2db55a5133fbc46b6f0ef49972cd1a80843b72e135aba", size = 57363, upload-time = "2025-08-20T11:56:09.758Z" }, - { url = "https://files.pythonhosted.org/packages/7b/71/a2b8c19cf4e1efe53cf439cdf7198ac60ae15471d2f1040b490c1f0f831f/ujson-5.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ab2cb8351d976e788669c8281465d44d4e94413718af497b4e7342d7b2f78018", size = 1036394, upload-time = "2025-08-20T11:56:11.168Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3e/7b98668cba3bb3735929c31b999b374ebc02c19dfa98dfebaeeb5c8597ca/ujson-5.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:090b4d11b380ae25453100b722d0609d5051ffe98f80ec52853ccf8249dfd840", size = 1195837, upload-time = "2025-08-20T11:56:12.6Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/8870f208c20b43571a5c409ebb2fe9b9dba5f494e9e60f9314ac01ea8f78/ujson-5.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:80017e870d882d5517d28995b62e4e518a894f932f1e242cbc802a2fd64d365c", size = 1088837, upload-time = "2025-08-20T11:56:14.15Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/c0e6607e37fa47929920a685a968c6b990a802dec65e9c5181e97845985d/ujson-5.11.0-cp314-cp314-win32.whl", hash = "sha256:1d663b96eb34c93392e9caae19c099ec4133ba21654b081956613327f0e973ac", size = 41022, upload-time = "2025-08-20T11:56:15.509Z" }, - { url = "https://files.pythonhosted.org/packages/4e/56/f4fe86b4c9000affd63e9219e59b222dc48b01c534533093e798bf617a7e/ujson-5.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:849e65b696f0d242833f1df4182096cedc50d414215d1371fca85c541fbff629", size = 45111, upload-time = "2025-08-20T11:56:16.597Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f3/669437f0280308db4783b12a6d88c00730b394327d8334cc7a32ef218e64/ujson-5.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:e73df8648c9470af2b6a6bf5250d4744ad2cf3d774dcf8c6e31f018bdd04d764", size = 39682, upload-time = "2025-08-20T11:56:17.763Z" }, - { url = "https://files.pythonhosted.org/packages/6e/cd/e9809b064a89fe5c4184649adeb13c1b98652db3f8518980b04227358574/ujson-5.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:de6e88f62796372fba1de973c11138f197d3e0e1d80bcb2b8aae1e826096d433", size = 55759, upload-time = "2025-08-20T11:56:18.882Z" }, - { url = "https://files.pythonhosted.org/packages/1b/be/ae26a6321179ebbb3a2e2685b9007c71bcda41ad7a77bbbe164005e956fc/ujson-5.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e56ef8066f11b80d620985ae36869a3ff7e4b74c3b6129182ec5d1df0255f3", size = 53634, upload-time = "2025-08-20T11:56:20.012Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e9/fb4a220ee6939db099f4cfeeae796ecb91e7584ad4d445d4ca7f994a9135/ujson-5.11.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a325fd2c3a056cf6c8e023f74a0c478dd282a93141356ae7f16d5309f5ff823", size = 58547, upload-time = "2025-08-20T11:56:21.175Z" }, - { url = "https://files.pythonhosted.org/packages/bd/f8/fc4b952b8f5fea09ea3397a0bd0ad019e474b204cabcb947cead5d4d1ffc/ujson-5.11.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:a0af6574fc1d9d53f4ff371f58c96673e6d988ed2b5bf666a6143c782fa007e9", size = 60489, upload-time = "2025-08-20T11:56:22.342Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e5/af5491dfda4f8b77e24cf3da68ee0d1552f99a13e5c622f4cef1380925c3/ujson-5.11.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10f29e71ecf4ecd93a6610bd8efa8e7b6467454a363c3d6416db65de883eb076", size = 58035, upload-time = "2025-08-20T11:56:23.92Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/0945349dd41f25cc8c38d78ace49f14c5052c5bbb7257d2f466fa7bdb533/ujson-5.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a0a9b76a89827a592656fe12e000cf4f12da9692f51a841a4a07aa4c7ecc41c", size = 1037212, upload-time = "2025-08-20T11:56:25.274Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/8e04496acb3d5a1cbee3a54828d9652f67a37523efa3d3b18a347339680a/ujson-5.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b16930f6a0753cdc7d637b33b4e8f10d5e351e1fb83872ba6375f1e87be39746", size = 1196500, upload-time = "2025-08-20T11:56:27.517Z" }, - { url = "https://files.pythonhosted.org/packages/64/ae/4bc825860d679a0f208a19af2f39206dfd804ace2403330fdc3170334a2f/ujson-5.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04c41afc195fd477a59db3a84d5b83a871bd648ef371cf8c6f43072d89144eef", size = 1089487, upload-time = "2025-08-20T11:56:29.07Z" }, - { url = "https://files.pythonhosted.org/packages/30/ed/5a057199fb0a5deabe0957073a1c1c1c02a3e99476cd03daee98ea21fa57/ujson-5.11.0-cp314-cp314t-win32.whl", hash = "sha256:aa6d7a5e09217ff93234e050e3e380da62b084e26b9f2e277d2606406a2fc2e5", size = 41859, upload-time = "2025-08-20T11:56:30.495Z" }, - { url = "https://files.pythonhosted.org/packages/aa/03/b19c6176bdf1dc13ed84b886e99677a52764861b6cc023d5e7b6ebda249d/ujson-5.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:48055e1061c1bb1f79e75b4ac39e821f3f35a9b82de17fce92c3140149009bec", size = 46183, upload-time = "2025-08-20T11:56:31.574Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ca/a0413a3874b2dc1708b8796ca895bf363292f9c70b2e8ca482b7dbc0259d/ujson-5.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1194b943e951092db611011cb8dbdb6cf94a3b816ed07906e14d3bc6ce0e90ab", size = 40264, upload-time = "2025-08-20T11:56:32.773Z" }, -] - -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "uwsgi" -version = "2.0.31" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/49/2f57640e889ba509fd1fae10cccec1b58972a07c2724486efba94c5ea448/uwsgi-2.0.31.tar.gz", hash = "sha256:e8f8b350ccc106ff93a65247b9136f529c14bf96b936ac5b264c6ff9d0c76257", size = 822796, upload-time = "2025-10-11T19:17:28.794Z" } - -[[package]] -name = "vine" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/07/0b5bcc9812b1b2fd331cc88289ef4d47d428afdbbf0216bb7d53942d93d6/wcwidth-0.3.2.tar.gz", hash = "sha256:d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad", size = 233633, upload-time = "2026-01-23T21:08:52.451Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315", size = 86280, upload-time = "2026-01-23T21:08:51.362Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - -[[package]] -name = "wsproto" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, -] - -[[package]] -name = "yt-dlp" -version = "2025.12.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/77/db924ebbd99d0b2b571c184cb08ed232cf4906c6f9b76eed763cd2c84170/yt_dlp-2025.12.8.tar.gz", hash = "sha256:b773c81bb6b71cb2c111cfb859f453c7a71cf2ef44eff234ff155877184c3e4f", size = 3088947, upload-time = "2025-12-08T00:16:01.649Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/2f/98c3596ad923f8efd32c90dca62e241e8ad9efcebf20831173c357042ba0/yt_dlp-2025.12.8-py3-none-any.whl", hash = "sha256:36e2584342e409cfbfa0b5e61448a1c5189e345cf4564294456ee509e7d3e065", size = 3291464, upload-time = "2025-12-08T00:15:58.556Z" }, -] - -[[package]] -name = "zope-event" -version = "6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, -] - -[[package]] -name = "zope-interface" -version = "8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" }, - { url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" }, - { url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" }, - { url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" }, - { url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" }, - { url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" }, - { url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" }, - { url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" }, -] From ac48580e637d8b54ad89786056dcf4cdee0d24b7 Mon Sep 17 00:00:00 2001 From: None Date: Thu, 29 Jan 2026 20:35:20 -0600 Subject: [PATCH 27/63] Move EPG match settings from Settings page to auto-match modal Relocate EPG matching configuration (ignore prefixes, suffixes, and custom strings) from the Settings page to a dedicated modal that opens when triggering auto-match. Changes: - frontend/src/components/modals/EPGMatchModal.jsx: Added new modal that combines EPG match settings configuration with the auto-match trigger. - frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx: Added EPGMatchModal import and state. Changed menu item to open modal instead of directly calling API. Removed inline matchEpg function (logic moved to modal) - frontend/src/pages/Settings.jsx: Removed EPG Settings accordion (now accessible via modal) - frontend/src/components/forms/settings/EPGSettingsForm.jsx: Deleted - functionality replaced by EPGMatchModal - frontend/src/utils/forms/settings/EPGSettingsFormUtils.js: Deleted - was only used by EPGSettingsForm (now defunct) --- .../forms/settings/EPGSettingsForm.jsx | 102 ---------- .../src/components/modals/EPGMatchModal.jsx | 178 ++++++++++++++++++ .../ChannelsTable/ChannelTableHeader.jsx | 31 +-- frontend/src/pages/Settings.jsx | 14 -- .../forms/settings/EPGSettingsFormUtils.js | 11 -- 5 files changed, 188 insertions(+), 148 deletions(-) delete mode 100644 frontend/src/components/forms/settings/EPGSettingsForm.jsx create mode 100644 frontend/src/components/modals/EPGMatchModal.jsx delete mode 100644 frontend/src/utils/forms/settings/EPGSettingsFormUtils.js diff --git a/frontend/src/components/forms/settings/EPGSettingsForm.jsx b/frontend/src/components/forms/settings/EPGSettingsForm.jsx deleted file mode 100644 index 7cfcc513..00000000 --- a/frontend/src/components/forms/settings/EPGSettingsForm.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import useSettingsStore from '../../../store/settings.jsx'; -import React, { useEffect, useState } from 'react'; -import { - getChangedSettings, - parseSettings, - saveChangedSettings, -} from '../../../utils/pages/SettingsUtils.js'; -import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { getEPGSettingsFormInitialValues } from '../../../utils/forms/settings/EPGSettingsFormUtils.js'; - -const EPGSettingsForm = React.memo(({ active }) => { - const settings = useSettingsStore((s) => s.settings); - - const [saved, setSaved] = useState(false); - - const form = useForm({ - mode: 'controlled', - initialValues: getEPGSettingsFormInitialValues(), - }); - - useEffect(() => { - if (!active) setSaved(false); - }, [active]); - - useEffect(() => { - if (settings) { - const formValues = parseSettings(settings); - - form.setValues(formValues); - } - }, [settings]); - - const onSubmit = async () => { - setSaved(false); - - const changedSettings = getChangedSettings(form.getValues(), settings); - - // Update each changed setting in the backend (create if missing) - try { - await saveChangedSettings(settings, changedSettings); - - setSaved(true); - } catch (error) { - // Error notifications are already shown by API functions - // Just don't show the success message - console.error('Error saving settings:', error); - } - }; - - return ( - - {saved && ( - - )} - - Configure how channel names are normalized during EPG matching. - These settings help channels with different names match the same EPG data. - Channel display names are never modified. - - - - - - - - - - - - - ); -}); - -export default EPGSettingsForm; diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx new file mode 100644 index 00000000..fc773d94 --- /dev/null +++ b/frontend/src/components/modals/EPGMatchModal.jsx @@ -0,0 +1,178 @@ +import { useMemo, useState, useEffect } from 'react'; +import { + Modal, + Stack, + Text, + TagsInput, + Group, + Button, + Loader, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import useSettingsStore from '../../store/settings'; +import API from '../../api'; +import { + getChangedSettings, + saveChangedSettings, +} from '../../utils/pages/SettingsUtils'; + +// Extract EPG settings directly without parsing all settings +const getEpgSettingsFromStore = (settings) => { + const epgSettings = settings?.['epg_settings']?.value; + return { + epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes) + ? epgSettings.epg_match_ignore_prefixes + : [], + epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes) + ? epgSettings.epg_match_ignore_suffixes + : [], + epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom) + ? epgSettings.epg_match_ignore_custom + : [], + }; +}; + +const EPGMatchModal = ({ + opened, + onClose, + selectedChannelIds = [], +}) => { + const settings = useSettingsStore((s) => s.settings); + + const [loading, setLoading] = useState(false); + + // Compute form values directly from settings - memoized for performance + const storedValues = useMemo( + () => getEpgSettingsFromStore(settings), + [settings] + ); + + // Local form state + const [formValues, setFormValues] = useState(storedValues); + + // Reset to stored values when modal opens + useEffect(() => { + if (opened) { + setFormValues(storedValues); + } + }, [opened, storedValues]); + + const handleConfirm = async () => { + setLoading(true); + try { + // Save settings first + const changedSettings = getChangedSettings(formValues, settings); + if (Object.keys(changedSettings).length > 0) { + await saveChangedSettings(settings, changedSettings); + } + + // Then trigger auto-match + if (selectedChannelIds.length > 0) { + await API.matchEpg(selectedChannelIds); + notifications.show({ + title: `EPG matching started for ${selectedChannelIds.length} selected channel(s)`, + color: 'green', + }); + } else { + await API.matchEpg(); + notifications.show({ + title: 'EPG matching started for all channels without EPG', + color: 'green', + }); + } + + onClose(); + } catch (error) { + console.error('Error during auto-match:', error); + notifications.show({ + title: 'Error', + message: error.message || 'Failed to start EPG matching', + color: 'red', + }); + } finally { + setLoading(false); + } + }; + + const scopeText = selectedChannelIds.length > 0 + ? `${selectedChannelIds.length} selected channel(s)` + : 'all channels without EPG'; + + return ( + + + + Configure how channel names are normalized during matching, then start + the auto-match process for {scopeText}. + + + + setFormValues((prev) => ({ + ...prev, + epg_match_ignore_prefixes: value, + })) + } + splitChars={[]} + clearable + /> + + + setFormValues((prev) => ({ + ...prev, + epg_match_ignore_suffixes: value, + })) + } + splitChars={[]} + clearable + /> + + + setFormValues((prev) => ({ + ...prev, + epg_match_ignore_custom: value, + })) + } + splitChars={[]} + clearable + /> + + + Channel display names are never modified. These settings only affect + the matching algorithm. + + + + + + + + + ); +}; + +export default EPGMatchModal; diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 3410879a..4e549699 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -44,6 +44,7 @@ import GroupManager from '../../forms/GroupManager'; import ConfirmationDialog from '../../ConfirmationDialog'; import useWarningsStore from '../../../store/warnings'; import ProfileModal, { renderProfileOption } from '../../modals/ProfileModal'; +import EPGMatchModal from '../../modals/EPGMatchModal'; const CreateProfilePopover = React.memo(() => { const [opened, setOpened] = useState(false); @@ -121,6 +122,7 @@ const ChannelTableHeader = ({ const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1); const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false); const [groupManagerOpen, setGroupManagerOpen] = useState(false); + const [epgMatchModalOpen, setEpgMatchModalOpen] = useState(false); const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); const [profileToDelete, setProfileToDelete] = useState(null); @@ -178,26 +180,7 @@ const ChannelTableHeader = ({ } }; - const matchEpg = async () => { - try { - // Hit our new endpoint that triggers the fuzzy matching Celery task - // If channels are selected, only match those; otherwise match all - if (selectedTableIds.length > 0) { - await API.matchEpg(selectedTableIds); - notifications.show({ - title: `EPG matching task started for ${selectedTableIds.length} selected channel(s)!`, - }); - } else { - await API.matchEpg(); - notifications.show({ - title: 'EPG matching task started for all channels without EPG!', - }); - } - } catch (err) { - notifications.show(`Error: ${err.message}`); - } - }; - + const assignChannels = async () => { try { // Call our custom API endpoint @@ -421,7 +404,7 @@ const ChannelTableHeader = ({ } disabled={authUser.user_level != USER_LEVELS.ADMIN} - onClick={matchEpg} + onClick={() => setEpgMatchModalOpen(true)} > {selectedTableIds.length > 0 @@ -465,6 +448,12 @@ const ChannelTableHeader = ({ onClose={() => setGroupManagerOpen(false)} /> + setEpgMatchModalOpen(false)} + selectedChannelIds={selectedTableIds} + /> + setConfirmDeleteProfileOpen(false)} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index bbe4631f..4ce519a3 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -25,8 +25,6 @@ const ProxySettingsForm = React.lazy(() => import('../components/forms/settings/ProxySettingsForm.jsx')); const StreamSettingsForm = React.lazy(() => import('../components/forms/settings/StreamSettingsForm.jsx')); -const EPGSettingsForm = React.lazy(() => - import('../components/forms/settings/EPGSettingsForm.jsx')); const DvrSettingsForm = React.lazy(() => import('../components/forms/settings/DvrSettingsForm.jsx')); const SystemSettingsForm = React.lazy(() => @@ -80,18 +78,6 @@ const SettingsPage = () => { - - EPG Settings - - - }> - - - - - - System Settings diff --git a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js b/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js deleted file mode 100644 index 7babbbf9..00000000 --- a/frontend/src/utils/forms/settings/EPGSettingsFormUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -export const getEPGSettingsFormInitialValues = () => { - return { - epg_match_ignore_prefixes: [], - epg_match_ignore_suffixes: [], - epg_match_ignore_custom: [], - }; -}; - -export const getEPGSettingsFormValidation = () => { - return {}; -}; From a3ed1d96283b2bb95edb8928967ddb7c2ea5df43 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 30 Jan 2026 12:12:42 -0600 Subject: [PATCH 28/63] Refactor for uv transition. --- .dockerignore | 1 - docker/DispatcharrBase | 14 ++++++++------ docker/entrypoint.sh | 6 +++--- docker/init/99-init-dev.sh | 4 ++-- pyproject.toml | 7 +++++-- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index 296537de..002f1c63 100755 --- a/.dockerignore +++ b/.dockerignore @@ -29,6 +29,5 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md data/ docker/data/ diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index d89f334f..d2e8ceaa 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,6 +1,7 @@ FROM lscr.io/linuxserver/ffmpeg:latest ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy ENV VIRTUAL_ENV=/dispatcharrpy ENV PATH="$VIRTUAL_ENV/bin:$PATH" ENV UV_COMPILE_BYTECODE=1 @@ -26,21 +27,22 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ # --- Create Python virtual environment and install dependencies --- WORKDIR /tmp/build COPY pyproject.toml /tmp/build/ -RUN uv venv $VIRTUAL_ENV --python 3.13 --seed && \ - uv sync --python $VIRTUAL_ENV/bin/python --no-cache --no-install-project --no-dev && \ +COPY version.py /tmp/build/ +COPY README.md /tmp/build/ +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ rm -rf /tmp/build WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- -RUN uv pip install --python $VIRTUAL_ENV/bin/python --no-cache build && \ +RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ cd /tmp && \ - $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ tar -xzf numpy-*.tar.gz && \ cd numpy-*/ && \ - $VIRTUAL_ENV/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ - uv pip uninstall --python $VIRTUAL_ENV/bin/python build + uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip # --- Clean up build dependencies to reduce image size --- RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a50f2f49..ff8a193f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -30,9 +30,9 @@ echo_with_timestamp() { # --- NumPy version switching for legacy hardware --- if [ "$USE_LEGACY_NUMPY" = "true" ]; then # Check if NumPy was compiled with baseline support - if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then + if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." - /dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl + uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl echo_with_timestamp "✅ Legacy NumPy installed" else echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" @@ -214,7 +214,7 @@ fi # Users can override via UWSGI_NICE_LEVEL environment variable in docker-compose # Start with nice as root, then use setpriv to drop privileges to dispatch user # This preserves both the nice value and environment variables -nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec /dispatcharrpy/bin/uwsgi $uwsgi_args" & uwsgi_pid=$! +nice -n $UWSGI_NICE_LEVEL su - "$POSTGRES_USER" -c "cd /app && exec $VIRTUAL_ENV/bin/uwsgi $uwsgi_args" & uwsgi_pid=$! echo "✅ uwsgi started with PID $uwsgi_pid (nice $UWSGI_NICE_LEVEL)" pids+=("$uwsgi_pid") diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh index c89f1e44..5afbc96d 100644 --- a/docker/init/99-init-dev.sh +++ b/docker/init/99-init-dev.sh @@ -16,10 +16,10 @@ fi # Install frontend dependencies cd /app/frontend && npm install # Install Python dependencies using UV -cd /app && uv sync --python $VIRTUAL_ENV/bin/python --no-install-project --no-dev +cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev # Install debugpy for remote debugging if [ "$DISPATCHARR_DEBUG" = "true" ]; then echo "=== setting up debugpy ===" - uv pip install --python $VIRTUAL_ENV/bin/python debugpy + uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy fi diff --git a/pyproject.toml b/pyproject.toml index bdd056cc..9400a55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [project] name = "dispatcharr" -version = "0.17.0" description = "Dispatcharr - Stream dispatching and management" readme = "README.md" -license = "MIT" +license = "CC-BY-NC-SA-4.0" requires-python = ">=3.13" +dynamic = ["version"] dependencies = [ "Django==5.2.9", "psycopg2-binary==2.9.11", @@ -51,3 +51,6 @@ torch = { index = "pytorch-cpu" } [tool.hatch.build.targets.wheel] packages = ["dispatcharr", "apps", "core"] + +[tool.hatch.version] +path = "version.py" From 256d19a2cc76b02c59d2dedc6e8516592ab65cf1 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 30 Jan 2026 12:45:11 -0600 Subject: [PATCH 29/63] Move legacy numpy install to after init-dev. UV replaces. --- docker/entrypoint.sh | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ff8a193f..0f8f5f88 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -27,18 +27,6 @@ echo_with_timestamp() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" } -# --- NumPy version switching for legacy hardware --- -if [ "$USE_LEGACY_NUMPY" = "true" ]; then - # Check if NumPy was compiled with baseline support - if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then - echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." - uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl - echo_with_timestamp "✅ Legacy NumPy installed" - else - echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" - fi -fi - # Set PostgreSQL environment variables export POSTGRES_DB=${POSTGRES_DB:-dispatcharr} export POSTGRES_USER=${POSTGRES_USER:-dispatch} @@ -186,6 +174,19 @@ else pids+=("$nginx_pid") fi + +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl + echo_with_timestamp "✅ Legacy NumPy installed" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + # Run Django commands as non-root user to prevent permission issues su - $POSTGRES_USER -c "cd /app && python manage.py migrate --noinput" su - $POSTGRES_USER -c "cd /app && python manage.py collectstatic --noinput" From fca50e15704c07493006e1086bd496ebc5677cb8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 30 Jan 2026 15:40:33 -0600 Subject: [PATCH 30/63] changelog: Update changelog for UV PR. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e28ecb11..efb7d6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Modern OpenAPI 3.0 specification compliance - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection +- Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started! ### Fixed From bdcc865b9db8e7f06f2489bf5aeaaee535d7a711 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 30 Jan 2026 16:35:56 -0600 Subject: [PATCH 31/63] Bug Fix: Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. --- CHANGELOG.md | 1 + docker/entrypoint.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb7d6ed..d05e10c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). - EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. ## [0.18.1] - 2026-01-27 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 0f8f5f88..1594385f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -178,7 +178,7 @@ fi # --- NumPy version switching for legacy hardware --- if [ "$USE_LEGACY_NUMPY" = "true" ]; then # Check if NumPy was compiled with baseline support - if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then + if $VIRTUAL_ENV/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." uv pip install --python $VIRTUAL_ENV/bin/python --no-cache --force-reinstall --no-deps /opt/numpy-*.whl echo_with_timestamp "✅ Legacy NumPy installed" From f8ec970561081c418aa1448cea586908ecf94be4 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 27 Jan 2026 22:16:33 -0600 Subject: [PATCH 32/63] Add TVG-ID column with filter and sort to Streams table Changes: - frontend/src/components/tables/StreamsTable.jsx Added TVG-ID column to streams table Added search filter input in column header Added sort button for ascending/descending order Column follows same pattern as Name, Group, and M3U columns - apps/channels/api_views.py Added TVG-ID to filterable fields Added TVG-ID to sortable fields Enables backend support for search and sorting Users can now view, search, and sort streams by their TVG-ID directly in the Streams table. Fulfills #866 --- apps/channels/api_views.py | 4 +- .../src/components/tables/StreamsTable.jsx | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 27e07dac..fd79807e 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -106,6 +106,7 @@ class StreamFilter(django_filters.FilterSet): m3u_account_is_active = django_filters.BooleanFilter( field_name="m3u_account__is_active" ) + tvg_id = django_filters.CharFilter(lookup_expr="icontains") class Meta: model = Stream @@ -115,6 +116,7 @@ class StreamFilter(django_filters.FilterSet): "m3u_account", "m3u_account_name", "m3u_account_is_active", + "tvg_id", ] @@ -129,7 +131,7 @@ class StreamViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = StreamFilter search_fields = ["name", "channel_group__name"] - ordering_fields = ["name", "channel_group__name", "m3u_account__name"] + ordering_fields = ["name", "channel_group__name", "m3u_account__name", "tvg_id"] ordering = ["-name"] def get_permissions(self): diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 64c087ef..3ac06ac1 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -370,6 +370,25 @@ const StreamsTable = ({ onReady }) => { ), }, + { + header: 'TVG-ID', + id: 'tvg_id', + accessorKey: 'tvg_id', + size: columnSizing.tvg_id || 120, + cell: ({ getValue }) => ( + + + {getValue()} + + + ), + }, ], [channelGroups, playlists, columnSizing] ); @@ -427,6 +446,7 @@ const StreamsTable = ({ onReady }) => { name: 'name', group: 'channel_group__name', m3u: 'm3u_account__name', + tvg_id: 'tvg_id', }; const sortField = fieldMapping[columnId] || columnId; const sortDirection = sorting[0].desc ? '-' : ''; @@ -967,6 +987,33 @@ const StreamsTable = ({ onReady }) => { ); } + + case 'tvg_id': + return ( + + e.stopPropagation()} + onChange={handleFilterChange} + size="xs" + variant="unstyled" + className="table-input-header" + leftSection={} + style={{ flex: 1, minWidth: 0 }} + rightSectionPointerEvents="auto" + rightSection={React.createElement(sortingIcon, { + onClick: (e) => { + e.stopPropagation(); + onSortingChange('tvg_id'); + }, + size: 14, + style: { cursor: 'pointer' }, + })} + /> + + ); } }; @@ -1019,6 +1066,7 @@ const StreamsTable = ({ onReady }) => { name: renderHeaderCell, group: renderHeaderCell, m3u: renderHeaderCell, + tvg_id: renderHeaderCell, }, bodyCellRenderFns: { actions: renderBodyCell, From 4a4df1495067d29fe5e6bd91504dfd0bbe94a04a Mon Sep 17 00:00:00 2001 From: None Date: Fri, 30 Jan 2026 23:30:36 -0600 Subject: [PATCH 33/63] Add column visibility toggle and Source Info column to StreamsTable - Add three-dot menu for toggling column visibility (Name, Group, M3U, TVG-ID, Source Info) - Add Source Info column showing stream resolution and codec with detailed tooltip - Column visibility persists to localStorage with proper migration handling for existing users - Default hidden columns: TVG-ID, Source Info - Auto-refresh table data when video preview closes to show updated stream stats - Fix state spreading bug in CustomTable/index.jsx (was spreading options.state instead of state) I think the people are gonna like this one! --- .../components/tables/CustomTable/index.jsx | 4 +- .../src/components/tables/StreamsTable.jsx | 210 ++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tables/CustomTable/index.jsx b/frontend/src/components/tables/CustomTable/index.jsx index d523d757..883e01d7 100644 --- a/frontend/src/components/tables/CustomTable/index.jsx +++ b/frontend/src/components/tables/CustomTable/index.jsx @@ -21,6 +21,7 @@ const useTable = ({ state = [], columnSizing, setColumnSizing, + onColumnVisibilityChange, ...options }) => { const [selectedTableIds, setSelectedTableIds] = useState([]); @@ -98,12 +99,13 @@ const useTable = ({ }, ...options, state: { - ...options.state, + ...state, selectedTableIds, ...(columnSizing && { columnSizing }), }, onStateChange: options.onStateChange, ...(setColumnSizing && { onColumnSizingChange: setColumnSizing }), + ...(onColumnVisibilityChange && { onColumnVisibilityChange }), getCoreRowModel: options.getCoreRowModel ?? getCoreRowModel(), enableColumnResizing: true, columnResizeMode: 'onChange', diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 3ac06ac1..e8d99ad7 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -23,6 +23,9 @@ import { Filter, Square, SquareCheck, + Eye, + EyeOff, + RotateCcw, } from 'lucide-react'; import { TextInput, @@ -242,6 +245,64 @@ const StreamsTable = ({ onReady }) => { 'streams-table-column-sizing', {} ); + + // Column visibility - persisted to localStorage + // Default visible: name, group, m3u + // Default hidden: tvg_id, source_info + const DEFAULT_COLUMN_VISIBILITY = { + actions: true, + select: true, + name: true, + group: true, + m3u: true, + tvg_id: false, + source_info: false, + }; + + const [storedColumnVisibility, setStoredColumnVisibility] = useLocalStorage( + 'streams-table-column-visibility', + null // Use null as default to detect fresh install + ); + + // Merge defaults with stored values, ensuring all columns have values + // - Fresh install (null): use defaults + // - Existing users: merge settings with defaults for any new columns + const columnVisibility = useMemo(() => { + if (!storedColumnVisibility || typeof storedColumnVisibility !== 'object') { + return DEFAULT_COLUMN_VISIBILITY; + } + // Merge: start with defaults, overlay stored values only for keys that exist in defaults + const merged = { ...DEFAULT_COLUMN_VISIBILITY }; + for (const key of Object.keys(DEFAULT_COLUMN_VISIBILITY)) { + if (key in storedColumnVisibility && typeof storedColumnVisibility[key] === 'boolean') { + merged[key] = storedColumnVisibility[key]; + } + } + return merged; + }, [storedColumnVisibility]); + + const setColumnVisibility = (newValue) => { + if (typeof newValue === 'function') { + setStoredColumnVisibility((prev) => { + const prevMerged = prev && typeof prev === 'object' ? { ...DEFAULT_COLUMN_VISIBILITY, ...prev } : DEFAULT_COLUMN_VISIBILITY; + return newValue(prevMerged); + }); + } else { + setStoredColumnVisibility(newValue); + } + }; + + const toggleColumnVisibility = (columnId) => { + setColumnVisibility((prev) => ({ + ...prev, + [columnId]: !prev[columnId], + })); + }; + + const resetColumnVisibility = () => { + setStoredColumnVisibility(DEFAULT_COLUMN_VISIBILITY); + }; + const debouncedFilters = useDebounce(filters, 500, () => { // Reset to first page whenever filters change to avoid "Invalid page" errors setPagination({ @@ -272,6 +333,7 @@ const StreamsTable = ({ onReady }) => { const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); + const videoIsVisible = useVideoStore((s) => s.isVisible); const data = useStreamsTableStore((s) => s.streams); const pageCount = useStreamsTableStore((s) => s.pageCount); @@ -389,6 +451,61 @@ const StreamsTable = ({ onReady }) => { ), }, + { + header: 'Source Info', + id: 'source_info', + accessorKey: 'stream_stats', + size: columnSizing.source_info || 120, + cell: ({ getValue }) => { + const stats = getValue(); + if (!stats) return -; + + // Build compact display (resolution + video codec) + const parts = []; + if (stats.resolution) { + // Convert "1920x1080" to "1080p" format + const height = stats.resolution.split('x')[1]; + if (height) parts.push(`${height}p`); + } + if (stats.video_codec) { + parts.push(stats.video_codec.toUpperCase()); + } + const compactDisplay = parts.length > 0 ? parts.join(' ') : '-'; + + // Build tooltip content with friendly labels + const tooltipLines = []; + if (stats.resolution) tooltipLines.push(`Resolution: ${stats.resolution}`); + if (stats.video_codec) tooltipLines.push(`Video Codec: ${stats.video_codec.toUpperCase()}`); + if (stats.video_bitrate) tooltipLines.push(`Video Bitrate: ${stats.video_bitrate} kbps`); + if (stats.source_fps) tooltipLines.push(`Frame Rate: ${stats.source_fps} FPS`); + if (stats.audio_codec) tooltipLines.push(`Audio Codec: ${stats.audio_codec.toUpperCase()}`); + if (stats.audio_channels) tooltipLines.push(`Audio Channels: ${stats.audio_channels}`); + if (stats.audio_bitrate) tooltipLines.push(`Audio Bitrate: ${stats.audio_bitrate} kbps`); + + const tooltipContent = tooltipLines.length > 0 + ? tooltipLines.join('\n') + : 'No source info available'; + + return ( + {tooltipContent}} + openDelay={500} + multiline + w={220} + > + + {compactDisplay} + + + ); + }, + }, ], [channelGroups, playlists, columnSizing] ); @@ -1053,6 +1170,7 @@ const StreamsTable = ({ onReady }) => { sorting, columnSizing, setColumnSizing, + onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: onRowSelectionChange, manualPagination: true, manualSorting: true, @@ -1061,6 +1179,7 @@ const StreamsTable = ({ onReady }) => { state: { pagination, sorting, + columnVisibility, }, headerCellRenderFns: { name: renderHeaderCell, @@ -1089,6 +1208,16 @@ const StreamsTable = ({ onReady }) => { fetchData(); }, [fetchData]); + // Refetch data when video player closes to update stream stats + const prevVideoVisible = useRef(false); + useEffect(() => { + if (prevVideoVisible.current && !videoIsVisible) { + // Video was closed, refetch to get updated stream stats + fetchData({ showLoader: false }); + } + prevVideoVisible.current = videoIsVisible; + }, [videoIsVisible, fetchData]); + useEffect(() => { if ( Object.keys(channelGroups).length > 0 || @@ -1306,6 +1435,87 @@ const StreamsTable = ({ onReady }) => { + + + + + + + + + + + Toggle Columns + toggleColumnVisibility('name')} + leftSection={ + columnVisibility.name !== false ? ( + + ) : ( + + ) + } + > + Name + + toggleColumnVisibility('group')} + leftSection={ + columnVisibility.group !== false ? ( + + ) : ( + + ) + } + > + Group + + toggleColumnVisibility('m3u')} + leftSection={ + columnVisibility.m3u !== false ? ( + + ) : ( + + ) + } + > + M3U + + toggleColumnVisibility('tvg_id')} + leftSection={ + columnVisibility.tvg_id !== false ? ( + + ) : ( + + ) + } + > + TVG-ID + + toggleColumnVisibility('source_info')} + leftSection={ + columnVisibility.source_info !== false ? ( + + ) : ( + + ) + } + > + Source Info + + + } + > + Reset to Default + + + + + + + + + + - + @@ -1495,16 +1584,16 @@ const StreamsTable = ({ onReady }) => { TVG-ID toggleColumnVisibility('source_info')} + onClick={() => toggleColumnVisibility('stats')} leftSection={ - columnVisibility.source_info !== false ? ( + columnVisibility.stats !== false ? ( ) : ( ) } > - Source Info + Stats { - - - - - - - - diff --git a/frontend/src/pages/Channels.jsx b/frontend/src/pages/Channels.jsx index b7b87b17..ca8438bb 100644 --- a/frontend/src/pages/Channels.jsx +++ b/frontend/src/pages/Channels.jsx @@ -79,19 +79,19 @@ const PageContent = () => { defaultSizes={allotmentSizes} h={'100%'} w={'100%'} - miw={'600px'} + miw={'625px'} className="custom-allotment" minSize={100} onChange={handleSplitChange} onResize={handleResize} > - + - + From 5ed42432fc3c6e85a1d9342a54b2514ca76457a0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 14:15:39 -0600 Subject: [PATCH 35/63] Bug Fix: Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) --- CHANGELOG.md | 1 + core/tasks.py | 247 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 166 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d05e10c0..e998420a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). - EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. diff --git a/core/tasks.py b/core/tasks.py index 207e7c5e..fd8a19b5 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -480,15 +480,18 @@ def rehash_streams(keys): try: batch_size = 1000 - queryset = Stream.objects.all() # Track statistics total_processed = 0 duplicates_merged = 0 + # hash_keys maps new_hash -> stream_id for streams we've already processed hash_keys = {} + # Track IDs of streams that have been deleted to avoid stale references + deleted_stream_ids = set() - total_records = queryset.count() - logger.info(f"Starting rehash of {total_records} streams with keys: {keys}") + # Get initial count for progress reporting + initial_total_records = Stream.objects.count() + logger.info(f"Starting rehash of {initial_total_records} streams with keys: {keys}") # Send initial WebSocket update send_websocket_update( @@ -499,103 +502,108 @@ def rehash_streams(keys): "type": "stream_rehash", "action": "starting", "progress": 0, - "total_records": total_records, - "message": f"Starting rehash of {total_records} streams" + "total_records": initial_total_records, + "message": f"Starting rehash of {initial_total_records} streams" } ) - for start in range(0, total_records, batch_size): + # Use ID-based pagination to handle deletions correctly + # This ensures we don't skip records when items are deleted + last_processed_id = 0 + batch_number = 0 + + while True: + batch_number += 1 batch_processed = 0 batch_duplicates = 0 with transaction.atomic(): - batch = queryset[start:start + batch_size] + # Fetch batch by ID ordering, using select_for_update to lock records + # This prevents race conditions and ensures we process each record exactly once + batch = list( + Stream.objects.filter(id__gt=last_processed_id) + .select_for_update(skip_locked=True, of=('self',)) + .select_related('channel_group', 'm3u_account') + .order_by('id')[:batch_size] + ) + + if not batch: + # No more records to process + break for obj in batch: + # Update the last processed ID for next batch + last_processed_id = obj.id + # Generate new hash group_name = obj.channel_group.name if obj.channel_group else None new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys, m3u_id=obj.m3u_account_id, group=group_name) - # Check if this hash already exists in our tracking dict or in database + # Check if this hash already exists in our tracking dict if new_hash in hash_keys: - # Found duplicate in current batch - merge the streams existing_stream_id = hash_keys[new_hash] - existing_stream = Stream.objects.get(id=existing_stream_id) - # Move any channel relationships from duplicate to existing stream - # Handle potential unique constraint violations - for channel_stream in ChannelStream.objects.filter(stream_id=obj.id): - # Check if this channel already has a relationship with the target stream - existing_relationship = ChannelStream.objects.filter( - channel_id=channel_stream.channel_id, - stream_id=existing_stream_id - ).first() + # Verify the target stream still exists and hasn't been deleted + if existing_stream_id in deleted_stream_ids: + # The target was deleted, so this stream becomes the new canonical one + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 + continue - if existing_relationship: - # Relationship already exists, just delete the duplicate - channel_stream.delete() - else: - # Safe to update the relationship - channel_stream.stream_id = existing_stream_id - channel_stream.save() + try: + existing_stream = Stream.objects.get(id=existing_stream_id) + except Stream.DoesNotExist: + # Target stream was deleted externally, make this the canonical one + deleted_stream_ids.add(existing_stream_id) + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 + continue - # Update the existing stream with the most recent data - if obj.updated_at > existing_stream.updated_at: - existing_stream.name = obj.name - existing_stream.url = obj.url - existing_stream.logo_url = obj.logo_url - existing_stream.tvg_id = obj.tvg_id - existing_stream.m3u_account = obj.m3u_account - existing_stream.channel_group = obj.channel_group - existing_stream.custom_properties = obj.custom_properties - existing_stream.last_seen = obj.last_seen - existing_stream.updated_at = obj.updated_at - existing_stream.save() + # Determine which stream to keep based on channel ordering + stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj) - # Delete the duplicate - obj.delete() + # Move channel relationships from the stream being deleted to the one being kept + _merge_stream_relationships(stream_to_delete, stream_to_keep) + + # Delete the duplicate FIRST to free up the unique hash constraint + deleted_stream_ids.add(stream_to_delete.id) + stream_to_delete.delete() batch_duplicates += 1 + + # Now safely set the hash on the kept stream (after deletion freed it up) + if stream_to_keep.stream_hash != new_hash: + stream_to_keep.stream_hash = new_hash + stream_to_keep.save(update_fields=['stream_hash']) + + # Update hash_keys to point to the kept stream + hash_keys[new_hash] = stream_to_keep.id else: - # Check if hash already exists in database (from previous batches or existing data) + # Check if hash already exists in database (from streams not yet processed) existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first() if existing_stream: - # Found duplicate in database - merge the streams - # Move any channel relationships from duplicate to existing stream - # Handle potential unique constraint violations - for channel_stream in ChannelStream.objects.filter(stream_id=obj.id): - # Check if this channel already has a relationship with the target stream - existing_relationship = ChannelStream.objects.filter( - channel_id=channel_stream.channel_id, - stream_id=existing_stream.id - ).first() + # Found duplicate in database - determine which to keep based on channel ordering + stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj) - if existing_relationship: - # Relationship already exists, just delete the duplicate - channel_stream.delete() - else: - # Safe to update the relationship - channel_stream.stream_id = existing_stream.id - channel_stream.save() + # Move channel relationships from the stream being deleted to the one being kept + _merge_stream_relationships(stream_to_delete, stream_to_keep) - # Update the existing stream with the most recent data - if obj.updated_at > existing_stream.updated_at: - existing_stream.name = obj.name - existing_stream.url = obj.url - existing_stream.logo_url = obj.logo_url - existing_stream.tvg_id = obj.tvg_id - existing_stream.m3u_account = obj.m3u_account - existing_stream.channel_group = obj.channel_group - existing_stream.custom_properties = obj.custom_properties - existing_stream.last_seen = obj.last_seen - existing_stream.updated_at = obj.updated_at - existing_stream.save() - - # Delete the duplicate - obj.delete() + # Delete the duplicate FIRST to free up the unique hash constraint + deleted_stream_ids.add(stream_to_delete.id) + stream_to_delete.delete() batch_duplicates += 1 - hash_keys[new_hash] = existing_stream.id + + # Now safely set the hash on the kept stream (after deletion freed it up) + if stream_to_keep.stream_hash != new_hash: + stream_to_keep.stream_hash = new_hash + stream_to_keep.save(update_fields=['stream_hash']) + + hash_keys[new_hash] = stream_to_keep.id else: - # Update hash for this stream + # No duplicate - update hash for this stream obj.stream_hash = new_hash obj.save(update_fields=['stream_hash']) hash_keys[new_hash] = obj.id @@ -605,10 +613,9 @@ def rehash_streams(keys): total_processed += batch_processed duplicates_merged += batch_duplicates - # Calculate progress percentage - progress_percent = int((total_processed / total_records) * 100) - current_batch = start // batch_size + 1 - total_batches = (total_records // batch_size) + 1 + # Calculate progress percentage based on initial count + # Cap at 99% until we're actually done to avoid showing 100% prematurely + progress_percent = min(99, int((total_processed / max(initial_total_records, 1)) * 100)) # Send progress update via WebSocket send_websocket_update( @@ -619,15 +626,14 @@ def rehash_streams(keys): "type": "stream_rehash", "action": "processing", "progress": progress_percent, - "batch": current_batch, - "total_batches": total_batches, + "batch": batch_number, "processed": total_processed, "duplicates_merged": duplicates_merged, - "message": f"Processed batch {current_batch}/{total_batches}: {batch_processed} streams, {batch_duplicates} duplicates merged" + "message": f"Processed batch {batch_number}: {batch_processed} streams, {batch_duplicates} duplicates merged" } ) - logger.info(f"Rehashed batch {current_batch}/{total_batches}: " + logger.info(f"Rehashed batch {batch_number}: " f"{batch_processed} processed, {batch_duplicates} duplicates merged") logger.info(f"Rehashing complete: {total_processed} streams processed, " @@ -654,7 +660,7 @@ def rehash_streams(keys): return f"Successfully rehashed {total_processed} streams" except Exception as e: - logger.error(f"Error during stream rehash: {e}") + logger.error(f"Error during stream rehash: {e}", exc_info=True) raise finally: # Always release all acquired M3U locks @@ -663,6 +669,83 @@ def rehash_streams(keys): logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts") +def _merge_stream_relationships(source_stream, target_stream): + """ + Move channel relationships from source_stream to target_stream. + Handles unique constraint violations by preserving existing relationships. + Preserves the best ordering when merging relationships. + """ + for channel_stream in ChannelStream.objects.filter(stream_id=source_stream.id): + # Check if this channel already has a relationship with the target stream + existing_relationship = ChannelStream.objects.filter( + channel_id=channel_stream.channel_id, + stream_id=target_stream.id + ).first() + + if existing_relationship: + # Relationship already exists - keep the one with better ordering (lower order value) + if channel_stream.order < existing_relationship.order: + existing_relationship.order = channel_stream.order + existing_relationship.save(update_fields=['order']) + # Delete the duplicate relationship + channel_stream.delete() + else: + # Safe to update the relationship + channel_stream.stream_id = target_stream.id + channel_stream.save() + + +def _get_best_channel_order(stream): + """ + Get the best (lowest) channel order for a stream. + Returns None if stream has no channel relationships. + Lower order value = better/higher position in the channel list. + """ + best_order = ChannelStream.objects.filter(stream_id=stream.id).order_by('order').values_list('order', flat=True).first() + return best_order + + +def _determine_stream_to_keep(stream_a, stream_b): + """ + Determine which stream should be kept when merging duplicates. + + Priority: + 1. Stream with better (lower) channel order wins + 2. If both have same order or neither has channel relationships, + keep the one with more recent updated_at + 3. If still tied, keep the one with the lower ID (more stable) + + Returns: (stream_to_keep, stream_to_delete) + """ + order_a = _get_best_channel_order(stream_a) + order_b = _get_best_channel_order(stream_b) + + # If one has channel relationships and the other doesn't, keep the one with relationships + if order_a is not None and order_b is None: + return (stream_a, stream_b) + if order_b is not None and order_a is None: + return (stream_b, stream_a) + + # If both have channel relationships, keep the one with better (lower) order + if order_a is not None and order_b is not None: + if order_a < order_b: + return (stream_a, stream_b) + elif order_b < order_a: + return (stream_b, stream_a) + # Same order, fall through to other criteria + + # Neither has relationships, or same order - use updated_at + if stream_a.updated_at > stream_b.updated_at: + return (stream_a, stream_b) + elif stream_b.updated_at > stream_a.updated_at: + return (stream_b, stream_a) + + # Same updated_at - keep lower ID for stability + if stream_a.id < stream_b.id: + return (stream_a, stream_b) + return (stream_b, stream_a) + + @shared_task def cleanup_vod_persistent_connections(): """Clean up stale VOD persistent connections""" From e4b312d136635321976e5ea9b4e0c3192eae723e Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 14:44:52 -0600 Subject: [PATCH 36/63] Enhancement: Requery channels and streams after rehashing completes --- CHANGELOG.md | 1 + frontend/src/WebSocket.jsx | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e998420a..b3dc21a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations. - Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including: - `useLocalStorage` hook tests with localStorage mocking and error handling - `useSmartLogos` hook tests for logo loading and management diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 72bbb39a..034418f6 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -700,6 +700,17 @@ export const WebsocketProvider = ({ children }) => { withCloseButton: true, // Allow manual close loading: false, // Remove loading indicator }); + // Requery streams and channels after rehash completes + try { + await API.requeryChannels(); + await API.requeryStreams(); + await useChannelsStore.getState().fetchChannels(); + } catch (error) { + console.error( + 'Error refreshing channels/streams after rehash:', + error + ); + } } else if (parsedEvent.data.action === 'blocked') { // Handle blocked rehash attempt notifications.show({ From 9ffd595c96e290e21152d513c6b3af1662c915d5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 17:47:46 -0600 Subject: [PATCH 37/63] Enhancement: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. --- CHANGELOG.md | 1 + apps/channels/api_views.py | 18 +- .../migrations/0033_stream_id_stream_chno.py | 205 ++++++++++++++++++ apps/channels/models.py | 30 ++- apps/channels/serializers.py | 4 +- apps/channels/tasks.py | 10 +- apps/m3u/tasks.py | 79 ++++++- core/tasks.py | 11 +- 8 files changed, 325 insertions(+), 33 deletions(-) create mode 100644 apps/channels/migrations/0033_stream_id_stream_chno.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b3dc21a6..f48d9456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added - Modern OpenAPI 3.0 specification compliance diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 27e07dac..0ab48861 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -907,18 +907,13 @@ class ChannelViewSet(viewsets.ModelViewSet): if name is None: name = stream.name - # Check if client provided a channel_number; if not, auto-assign one. - stream_custom_props = stream.custom_properties or {} + # Check if client provided a channel_number; if not, use stream_chno or auto-assign channel_number = request.data.get("channel_number") if channel_number is None: - # Channel number not provided by client, check stream properties or auto-assign - if "tvg-chno" in stream_custom_props: - channel_number = float(stream_custom_props["tvg-chno"]) - elif "channel-number" in stream_custom_props: - channel_number = float(stream_custom_props["channel-number"]) - elif "num" in stream_custom_props: - channel_number = float(stream_custom_props["num"]) + # Channel number not provided by client, check stream's channel number or auto-assign + if stream.stream_chno is not None: + channel_number = stream.stream_chno elif channel_number == 0: # Special case: 0 means ignore provider numbers and auto-assign channel_number = None @@ -939,9 +934,8 @@ class ChannelViewSet(viewsets.ModelViewSet): if Channel.objects.filter(channel_number=channel_number).exists(): channel_number = Channel.get_next_available_channel_number(channel_number) # Get the tvc_guide_stationid from custom properties if it exists - tvc_guide_stationid = None - if "tvc-guide-stationid" in stream_custom_props: - tvc_guide_stationid = stream_custom_props["tvc-guide-stationid"] + stream_custom_props = stream.custom_properties or {} + tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid") channel_data = { "channel_number": channel_number, diff --git a/apps/channels/migrations/0033_stream_id_stream_chno.py b/apps/channels/migrations/0033_stream_id_stream_chno.py new file mode 100644 index 00000000..31690a93 --- /dev/null +++ b/apps/channels/migrations/0033_stream_id_stream_chno.py @@ -0,0 +1,205 @@ +# Generated by Django - Add stream_id and channel_number fields with data migration + +from django.db import migrations, models +import hashlib +import json +import logging + +logger = logging.getLogger(__name__) + + +def populate_fields_and_rehash(apps, schema_editor): + """ + Populate stream_id and stream_chno from custom_properties for XC account streams, + populate stream_chno from tvg-chno for standard M3U accounts, + then rehash XC streams using stable hash keys. + """ + Stream = apps.get_model('dispatcharr_channels', 'Stream') + M3UAccount = apps.get_model('m3u', 'M3UAccount') + CoreSettings = apps.get_model('core', 'CoreSettings') + + # Get hash keys from settings + try: + stream_settings = CoreSettings.objects.get(key='stream_settings') + hash_key_str = stream_settings.value.get('m3u_hash_key', '') if stream_settings.value else '' + keys = [k.strip() for k in hash_key_str.split(',') if k.strip()] if hash_key_str else [] + except CoreSettings.DoesNotExist: + keys = [] + + logger.info(f"Using hash keys: {keys}") + + # Get XC account IDs + xc_account_ids = set( + M3UAccount.objects.filter(account_type='XC').values_list('id', flat=True) + ) + + logger.info(f"Found {len(xc_account_ids)} XC accounts") + + # Track hash collisions for XC streams + hash_map = {} # new_hash -> stream_id + duplicates_to_delete = [] + + # Process all streams in batches + batch_size = 1000 + processed = 0 + updated = 0 + + total_count = Stream.objects.count() + logger.info(f"Processing {total_count} total streams") + + streams_to_update = [] + + for stream in Stream.objects.select_related('channel_group', 'm3u_account').iterator(chunk_size=batch_size): + processed += 1 + needs_update = False + + custom_props = stream.custom_properties or {} + is_xc = stream.m3u_account_id in xc_account_ids if stream.m3u_account_id else False + + # Extract stream_id (XC accounts only) + if is_xc and isinstance(custom_props, dict): + provider_stream_id = custom_props.get('stream_id') + if provider_stream_id: + try: + stream.stream_id = int(provider_stream_id) + needs_update = True + except (ValueError, TypeError): + pass + + # Extract stream_chno + channel_num = None + if isinstance(custom_props, dict): + if is_xc: + # XC accounts use 'num' + channel_num = custom_props.get('num') + else: + # Standard M3U accounts use 'tvg-chno' or 'channel-number' (case insensitive check) + for key in ['tvg-chno', 'TVG-CHNO', 'tvg-Chno', 'Tvg-Chno', 'channel-number', 'Channel-Number', 'CHANNEL-NUMBER']: + if key in custom_props: + channel_num = custom_props.get(key) + break + + if channel_num is not None: + try: + stream.stream_chno = float(channel_num) + needs_update = True + except (ValueError, TypeError): + pass + + # Rehash XC streams only when 'url' is in hash keys (otherwise hash wouldn't change) + if is_xc and stream.stream_id and keys and 'url' in keys: + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = stream.stream_id + + # Get group name + group_name = stream.channel_group.name if stream.channel_group else None + + # Build hash parts + stream_parts = { + "name": stream.name, + "url": effective_url, + "tvg_id": stream.tvg_id, + "m3u_id": stream.m3u_account_id, + "group": group_name + } + hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts} + + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = stream.m3u_account_id + + # Generate hash + serialized_obj = json.dumps(hash_parts, sort_keys=True) + new_hash = hashlib.sha256(serialized_obj.encode()).hexdigest() + + # Check for collisions + if new_hash in hash_map: + # Duplicate - mark for deletion (keep the first one) + duplicates_to_delete.append(stream.id) + continue + + hash_map[new_hash] = stream.id + stream.stream_hash = new_hash + needs_update = True + + if needs_update: + streams_to_update.append(stream) + updated += 1 + + # Bulk update in batches + if len(streams_to_update) >= batch_size: + Stream.objects.bulk_update( + streams_to_update, + ['stream_id', 'stream_chno', 'stream_hash'], + batch_size=500 + ) + logger.info(f"Updated batch: {processed}/{total_count} streams processed") + streams_to_update = [] + + # Final batch + if streams_to_update: + Stream.objects.bulk_update( + streams_to_update, + ['stream_id', 'stream_chno', 'stream_hash'], + batch_size=500 + ) + + # Delete duplicates if any + if duplicates_to_delete: + logger.warning(f"Deleting {len(duplicates_to_delete)} duplicate streams due to hash collisions") + Stream.objects.filter(id__in=duplicates_to_delete).delete() + + logger.info(f"Migration complete: {updated} streams updated, {len(duplicates_to_delete)} duplicates removed") + + +def reverse_migration(apps, schema_editor): + """ + Reverse migration - clear fields but don't attempt to reverse hash changes. + """ + Stream = apps.get_model('dispatcharr_channels', 'Stream') + Stream.objects.all().update(stream_id=None, stream_chno=None) + logger.info("Cleared stream_id and stream_chno fields. Note: stream hashes were not reverted.") + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0032_channel_is_adult_stream_is_adult'), + ('m3u', '0018_add_profile_custom_properties'), + ('core', '0020_change_coresettings_value_to_jsonfield'), + ] + + operations = [ + # Schema changes - add fields WITHOUT indexes first + migrations.AddField( + model_name='stream', + name='stream_id', + field=models.IntegerField( + blank=True, + help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', + null=True, + ), + ), + migrations.AddField( + model_name='stream', + name='stream_chno', + field=models.FloatField( + blank=True, + help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', + null=True, + ), + ), + # Data migration (may delete duplicates, which would conflict with pending index creation) + migrations.RunPython(populate_fields_and_rehash, reverse_migration), + # Add indexes AFTER data migration completes + migrations.AddIndex( + model_name='stream', + index=models.Index(fields=['stream_id'], name='dispatcharr_stream_id_idx'), + ), + migrations.AddIndex( + model_name='stream', + index=models.Index(fields=['stream_chno'], name='dispatcharr_stream_chno_idx'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 703498a8..ec5e135b 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -106,6 +106,19 @@ class Stream(models.Model): ) custom_properties = models.JSONField(default=dict, blank=True, null=True) + stream_id = models.IntegerField( + null=True, + blank=True, + db_index=True, + help_text="Provider stream ID (e.g., XC stream_id) for stable identity across credential changes" + ) + stream_chno = models.FloatField( + null=True, + blank=True, + db_index=True, + help_text="Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1" + ) + # Stream statistics fields stream_stats = models.JSONField( null=True, @@ -129,14 +142,27 @@ class Stream(models.Model): return self.name or self.url or f"Stream ID {self.id}" @classmethod - def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None): + def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None, + account_type=None, stream_id=None): if keys is None: keys = CoreSettings.get_m3u_hash_key().split(",") - stream_parts = {"name": name, "url": url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group} + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = url + use_stream_id = account_type == 'XC' and stream_id and 'url' in keys + if use_stream_id: + effective_url = stream_id + + stream_parts = {"name": name, "url": effective_url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group} hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts} + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if use_stream_id and 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = m3u_id + # Serialize and hash the dictionary serialized_obj = json.dumps( hash_parts, sort_keys=True diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 53c07292..abf26e91 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -104,7 +104,7 @@ class StreamSerializer(serializers.ModelSerializer): allow_null=True, required=False, ) - read_only_fields = ["is_custom", "m3u_account", "stream_hash"] + read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"] class Meta: model = Stream @@ -127,6 +127,8 @@ class StreamSerializer(serializers.ModelSerializer): "stream_hash", "stream_stats", "stream_stats_updated_at", + "stream_id", + "stream_chno", ] def get_fields(self): diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index b9983b87..fe0c2884 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -2629,13 +2629,9 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None channel_number = None if starting_channel_number is None: - # Mode 1: Use provider numbers when available - if "tvg-chno" in stream_custom_props: - channel_number = float(stream_custom_props["tvg-chno"]) - elif "channel-number" in stream_custom_props: - channel_number = float(stream_custom_props["channel-number"]) - elif "num" in stream_custom_props: - channel_number = float(stream_custom_props["num"]) + # Mode 1: Use provider numbers when available (from stream_chno field) + if stream.stream_chno is not None: + channel_number = stream.stream_chno # For modes 2 and 3 (starting_channel_number == 0 or specific number), # ignore provider numbers and use sequential assignment diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 82e4f213..d0d6978e 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -741,6 +741,7 @@ def collect_xc_streams(account_id, enabled_groups): "group-title": group_info["name"], # Preserve all XC stream properties as custom attributes "stream_id": str(stream.get("stream_id", "")), + "num": stream.get("num"), "category_id": category_id, "stream_type": stream.get("stream_type", ""), "added": stream.get("added", ""), @@ -749,7 +750,7 @@ def collect_xc_streams(account_id, enabled_groups): # Include any other properties that might be present **{k: str(v) for k, v in stream.items() if k not in [ "name", "stream_id", "epg_channel_id", "stream_icon", - "category_id", "stream_type", "added", "is_adult", "custom_sid" + "category_id", "stream_type", "added", "is_adult", "custom_sid", "num" ] and v is not None} } } @@ -817,13 +818,28 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): for stream in streams: name = stream["name"] + raw_stream_id = stream.get("stream_id", "") + provider_stream_id = None + if raw_stream_id: + try: + provider_stream_id = int(raw_stream_id) + except (ValueError, TypeError): + pass url = xc_client.get_stream_url(stream["stream_id"]) tvg_id = stream.get("epg_channel_id", "") tvg_logo = stream.get("stream_icon", "") group_title = group_name + stream_chno = stream.get("num") + # Convert stream_chno to float if valid, otherwise None + if stream_chno is not None: + try: + stream_chno = float(stream_chno) + except (ValueError, TypeError): + stream_chno = None stream_hash = Stream.generate_hash_key( - name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title + name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, + account_type='XC', stream_id=provider_stream_id ) stream_props = { "name": name, @@ -836,6 +852,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "custom_properties": stream, "is_adult": int(stream.get("is_adult", 0)) == 1, "is_stale": False, + "stream_id": provider_stream_id, + "stream_chno": stream_chno, } if stream_hash not in stream_hashes: @@ -850,7 +868,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno' ) } @@ -864,7 +882,9 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): obj.logo_url != stream_props["logo_url"] or obj.tvg_id != stream_props["tvg_id"] or obj.custom_properties != stream_props["custom_properties"] or - obj.is_adult != stream_props["is_adult"] + obj.is_adult != stream_props["is_adult"] or + obj.stream_id != stream_props["stream_id"] or + obj.stream_chno != stream_props["stream_chno"] ) if changed: @@ -900,7 +920,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): # Simplified bulk update for better performance Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], batch_size=150 # Smaller batch size for XC processing ) @@ -1003,7 +1023,42 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): ) continue - stream_hash = Stream.generate_hash_key(name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title) + # Determine provider-specific fields first + provider_stream_id = None + channel_num = None + account_type_for_hash = None + + if account.account_type == M3UAccount.Types.XC: + account_type_for_hash = 'XC' + raw_stream_id = stream_info["attributes"].get("stream_id", "") + if raw_stream_id: + try: + provider_stream_id = int(raw_stream_id) + except (ValueError, TypeError): + pass + raw_num = stream_info["attributes"].get("num") + if raw_num is not None: + try: + channel_num = float(raw_num) + except (ValueError, TypeError): + pass + else: + # For standard M3U accounts, check for tvg-chno or channel-number + tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "tvg-chno", None) + if tvg_chno is None: + tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "channel-number", None) + if tvg_chno is not None: + try: + channel_num = float(tvg_chno) + except (ValueError, TypeError): + pass + + # Generate hash once with all parameters + stream_hash = Stream.generate_hash_key( + name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, + account_type=account_type_for_hash, stream_id=provider_stream_id + ) + stream_props = { "name": name, "url": url, @@ -1015,6 +1070,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "custom_properties": stream_info["attributes"], "is_adult": int(stream_info["attributes"].get("is_adult", 0)) == 1, "is_stale": False, + "stream_id": provider_stream_id, + "stream_chno": channel_num, } if stream_hash not in stream_hashes: @@ -1026,7 +1083,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): existing_streams = { s.stream_hash: s for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( - 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account' + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno' ) } @@ -1040,7 +1097,9 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.logo_url != stream_props["logo_url"] or obj.tvg_id != stream_props["tvg_id"] or obj.custom_properties != stream_props["custom_properties"] or - obj.is_adult != stream_props["is_adult"] + obj.is_adult != stream_props["is_adult"] or + obj.stream_id != stream_props["stream_id"] or + obj.stream_chno != stream_props["stream_chno"] ) # Always update last_seen @@ -1054,6 +1113,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.tvg_id = stream_props["tvg_id"] obj.custom_properties = stream_props["custom_properties"] obj.is_adult = stream_props["is_adult"] + obj.stream_id = stream_props["stream_id"] + obj.stream_chno = stream_props["stream_chno"] obj.updated_at = timezone.now() # Always mark as not stale since we saw it in this refresh @@ -1076,7 +1137,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): # Update all streams in a single bulk operation Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], batch_size=200 ) except Exception as e: diff --git a/core/tasks.py b/core/tasks.py index fd8a19b5..78a3e0ec 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -535,9 +535,16 @@ def rehash_streams(keys): # Update the last processed ID for next batch last_processed_id = obj.id - # Generate new hash + # Generate new hash - handle XC accounts differently group_name = obj.channel_group.name if obj.channel_group else None - new_hash = Stream.generate_hash_key(obj.name, obj.url, obj.tvg_id, keys, m3u_id=obj.m3u_account_id, group=group_name) + account_type = obj.m3u_account.account_type if obj.m3u_account else None + stream_id_val = obj.stream_id if hasattr(obj, 'stream_id') else None + + new_hash = Stream.generate_hash_key( + obj.name, obj.url, obj.tvg_id, keys, + m3u_id=obj.m3u_account_id, group=group_name, + account_type=account_type, stream_id=stream_id_val + ) # Check if this hash already exists in our tracking dict if new_hash in hash_keys: From aa1f62740298d4345fc41f3536a4d51f4595c898 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 19:01:34 -0600 Subject: [PATCH 38/63] Enhancement: Refactored `copyToClipboard` utility function to include notification handling internally, eliminating duplicate notification code across the frontend. The function now accepts optional parameters for customizing success/failure messages while providing consistent behavior across all copy operations. --- CHANGELOG.md | 1 + frontend/src/components/SeriesModal.jsx | 10 ++-- frontend/src/components/Sidebar.jsx | 14 ++--- frontend/src/components/VODModal.jsx | 10 ++-- .../components/tables/ChannelTableStreams.jsx | 10 ++-- .../src/components/tables/ChannelsTable.jsx | 38 ++++---------- frontend/src/utils.js | 52 ++++++++++++++----- 7 files changed, 61 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f48d9456..152dd5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Better auto-generation of request/response schemas - Improved documentation accuracy with serializer introspection - Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started! +- Copy to Clipboard: Refactored `copyToClipboard` utility function to include notification handling internally, eliminating duplicate notification code across the frontend. The function now accepts optional parameters for customizing success/failure messages while providing consistent behavior across all copy operations. ### Fixed diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx index 05023712..c8e551af 100644 --- a/frontend/src/components/SeriesModal.jsx +++ b/frontend/src/components/SeriesModal.jsx @@ -287,13 +287,9 @@ const SeriesModal = ({ series, opened, onClose }) => { const handleCopyEpisodeLink = async (episode) => { const streamUrl = getEpisodeStreamUrl(episode); - const success = await copyToClipboard(streamUrl); - notifications.show({ - title: success ? 'Link Copied!' : 'Copy Failed', - message: success - ? 'Episode link copied to clipboard' - : 'Failed to copy link to clipboard', - color: success ? 'green' : 'red', + await copyToClipboard(streamUrl, { + successTitle: 'Link Copied!', + successMessage: 'Episode link copied to clipboard', }); }; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index a25aa301..435509e0 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -144,16 +144,10 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { // No need to fetch them again here - just use the store values const copyPublicIP = async () => { - const success = await copyToClipboard(environment.public_ip); - if (success) { - notifications.show({ - title: 'Success', - message: 'Public IP copied to clipboard', - color: 'green', - }); - } else { - console.error('Failed to copy public IP to clipboard'); - } + await copyToClipboard(environment.public_ip, { + successTitle: 'Success', + successMessage: 'Public IP copied to clipboard', + }); }; const onLogout = async () => { diff --git a/frontend/src/components/VODModal.jsx b/frontend/src/components/VODModal.jsx index 7df90ec0..f0ff7fec 100644 --- a/frontend/src/components/VODModal.jsx +++ b/frontend/src/components/VODModal.jsx @@ -268,13 +268,9 @@ const VODModal = ({ vod, opened, onClose }) => { const handleCopyLink = async () => { const streamUrl = getStreamUrl(); if (!streamUrl) return; - const success = await copyToClipboard(streamUrl); - notifications.show({ - title: success ? 'Link Copied!' : 'Copy Failed', - message: success - ? 'Stream link copied to clipboard' - : 'Failed to copy link to clipboard', - color: success ? 'green' : 'red', + await copyToClipboard(streamUrl, { + successTitle: 'Link Copied!', + successMessage: 'Stream link copied to clipboard', }); }; diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 0443b3ca..30dabf3d 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -379,13 +379,9 @@ const ChannelStreams = ({ channel, isExpanded }) => { style={{ cursor: 'pointer' }} onClick={async (e) => { e.stopPropagation(); - const success = await copyToClipboard(stream.url); - notifications.show({ - title: success ? 'URL Copied' : 'Copy Failed', - message: success - ? 'Stream URL copied to clipboard' - : 'Failed to copy URL to clipboard', - color: success ? 'green' : 'red', + await copyToClipboard(stream.url, { + successTitle: 'URL Copied', + successMessage: 'Stream URL copied to clipboard', }); }} > diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index f41ca72d..371cb77f 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -727,14 +727,6 @@ const ChannelsTable = ({ onReady }) => { setRecordingModalOpen(false); }; - const handleCopy = async (textToCopy, ref) => { - const success = await copyToClipboard(textToCopy); - notifications.show({ - title: success ? 'Copied!' : 'Copy Failed', - message: success ? undefined : 'Failed to copy to clipboard', - color: success ? 'green' : 'red', - }); - }; // Build URLs with parameters const buildM3UUrl = () => { const params = new URLSearchParams(); @@ -759,35 +751,23 @@ const ChannelsTable = ({ onReady }) => { }; // Example copy URLs const copyM3UUrl = async () => { - const success = await copyToClipboard(buildM3UUrl()); - notifications.show({ - title: success ? 'M3U URL Copied!' : 'Copy Failed', - message: success - ? 'The M3U URL has been copied to your clipboard.' - : 'Failed to copy M3U URL to clipboard', - color: success ? 'green' : 'red', + await copyToClipboard(buildM3UUrl(), { + successTitle: 'M3U URL Copied!', + successMessage: 'The M3U URL has been copied to your clipboard.', }); }; const copyEPGUrl = async () => { - const success = await copyToClipboard(buildEPGUrl()); - notifications.show({ - title: success ? 'EPG URL Copied!' : 'Copy Failed', - message: success - ? 'The EPG URL has been copied to your clipboard.' - : 'Failed to copy EPG URL to clipboard', - color: success ? 'green' : 'red', + await copyToClipboard(buildEPGUrl(), { + successTitle: 'EPG URL Copied!', + successMessage: 'The EPG URL has been copied to your clipboard.', }); }; const copyHDHRUrl = async () => { - const success = await copyToClipboard(hdhrUrl); - notifications.show({ - title: success ? 'HDHR URL Copied!' : 'Copy Failed', - message: success - ? 'The HDHR URL has been copied to your clipboard.' - : 'Failed to copy HDHR URL to clipboard', - color: success ? 'green' : 'red', + await copyToClipboard(hdhrUrl, { + successTitle: 'HDHR URL Copied!', + successMessage: 'The HDHR URL has been copied to your clipboard.', }); }; diff --git a/frontend/src/utils.js b/frontend/src/utils.js index bb1b6060..27abf86c 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; +import { notifications } from '@mantine/notifications'; export default { Limiter: (n, list) => { @@ -76,30 +77,53 @@ export function sleep(ms) { export const getDescendantProp = (obj, path) => path.split('.').reduce((acc, part) => acc && acc[part], obj); -export const copyToClipboard = async (value) => { +export const copyToClipboard = async (value, options = {}) => { + const { + successTitle = 'Copied!', + successMessage = 'Copied to clipboard', + failureTitle = 'Copy Failed', + failureMessage = 'Failed to copy to clipboard', + showNotification = true, + } = options; + + let success = false; + if (navigator.clipboard) { // Modern method, using navigator.clipboard try { await navigator.clipboard.writeText(value); - return true; + success = true; } catch (err) { console.error('Failed to copy: ', err); } } - // Fallback method for environments without clipboard support - try { - const textarea = document.createElement('textarea'); - textarea.value = value; - document.body.appendChild(textarea); - textarea.select(); - const successful = document.execCommand('copy'); - document.body.removeChild(textarea); - return successful; - } catch (err) { - console.error('Failed to copy with fallback method: ', err); - return false; + if (!success) { + // Fallback method for environments without clipboard support + try { + const textarea = document.createElement('textarea'); + textarea.value = value; + document.body.appendChild(textarea); + textarea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textarea); + success = successful; + } catch (err) { + console.error('Failed to copy with fallback method: ', err); + success = false; + } } + + // Show notification if enabled + if (showNotification) { + notifications.show({ + title: success ? successTitle : failureTitle, + message: success ? successMessage : failureMessage, + color: success ? 'green' : 'red', + }); + } + + return success; }; export const setCustomProperty = (input, key, value, serialize = false) => { From bbe4679a72edfbe7e589206bfa9a6cd426500dff Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 19:46:36 -0600 Subject: [PATCH 39/63] changelog: Update changelog for stream table PR. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 152dd5b8..3d520c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view. +- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations. - Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including: - `useLocalStorage` hook tests with localStorage mocking and error handling From 3c556494c168269b4f3ba9c18518dee380f30e74 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 20:26:45 -0600 Subject: [PATCH 40/63] Enhancement: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. --- CHANGELOG.md | 1 + dispatcharr/utils.py | 10 +++++++++- .../components/forms/settings/NetworkAccessForm.jsx | 7 ++++++- .../src/utils/forms/settings/NetworkAccessFormUtils.js | 6 +++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d520c02..8af6c6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. - Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index e588bcaa..c8fe58ad 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -43,11 +43,19 @@ def network_access_allowed(request, settings_key): network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value except CoreSettings.DoesNotExist: network_access = {} + local_cidrs = ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"] + # Set defaults based on endpoint type + if settings_key == "M3U_EPG": + # M3U/EPG endpoints: local IPv4 and IPv6 only by default + default_cidrs = local_cidrs + else: + # Other endpoints: allow all by default + default_cidrs = ["0.0.0.0/0", "::/0"] cidrs = ( network_access[settings_key].split(",") if settings_key in network_access - else ["0.0.0.0/0", "::/0"] + else default_cidrs ) network_allowed = False diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx index cc701f14..dd01e75a 100644 --- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx +++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx @@ -37,9 +37,14 @@ const NetworkAccessForm = React.memo(({ active }) => { useEffect(() => { const networkAccessSettings = settings['network_access']?.value || {}; + // M3U/EPG endpoints default to local networks only + const m3uEpgDefaults = + '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10'; networkAccessForm.setValues( Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { - acc[key] = networkAccessSettings[key] || '0.0.0.0/0,::/0'; + const defaultValue = + key === 'M3U_EPG' ? m3uEpgDefaults : '0.0.0.0/0,::/0'; + acc[key] = networkAccessSettings[key] || defaultValue; return acc; }, {}) ); diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js index fe1eea8a..e5b27d92 100644 --- a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js +++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js @@ -1,9 +1,13 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js'; import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js'; +// Default CIDR ranges for M3U/EPG endpoints (local networks only) +const M3U_EPG_DEFAULTS = '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10'; + export const getNetworkAccessFormInitialValues = () => { return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { - acc[key] = '0.0.0.0/0,::/0'; + // M3U/EPG endpoints default to local networks only + acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : '0.0.0.0/0,::/0'; return acc; }, {}); }; From dc51ab4dd10c4a7eae57ca60f9f18a0aaf4ced83 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 31 Jan 2026 20:35:48 -0600 Subject: [PATCH 41/63] Enhancement: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click. --- CHANGELOG.md | 1 + .../forms/settings/NetworkAccessForm.jsx | 14 +++++++++++++- .../utils/forms/settings/NetworkAccessFormUtils.js | 9 +++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af6c6a1..3966174a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click. - Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view. - Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations. diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx index dd01e75a..04ab60b0 100644 --- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx +++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx @@ -11,6 +11,7 @@ import ConfirmationDialog from '../../ConfirmationDialog.jsx'; import { getNetworkAccessFormInitialValues, getNetworkAccessFormValidation, + getNetworkAccessDefaults, } from '../../../utils/forms/settings/NetworkAccessFormUtils.js'; const NetworkAccessForm = React.memo(({ active }) => { @@ -50,6 +51,10 @@ const NetworkAccessForm = React.memo(({ active }) => { ); }, [settings]); + const resetNetworkAccessToDefaults = () => { + networkAccessForm.setValues(getNetworkAccessDefaults()); + }; + const onNetworkAccessSubmit = async () => { setSaved(false); setNetworkAccessError(null); @@ -125,7 +130,14 @@ const NetworkAccessForm = React.memo(({ active }) => { /> ))} - + + + ), + Group: ({ children }) =>
{children}
, + Loader: () =>
Loading...
, + Text: ({ children }) => {children}, + }; +}); + describe('EPGMatchModal', () => { const defaultProps = { opened: true, From 5c4a4580d6b0246b317cdfb041ef7f855756ad4b Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 1 Feb 2026 19:34:01 -0700 Subject: [PATCH 47/63] Fix backup scheduler test to match actual defaults The test expected enabled=False and retention_count=0, but commit 5371519d intentionally changed these defaults to enabled=True and retention_count=3. Update test to match the intended behavior. Co-Authored-By: Claude Opus 4.5 --- apps/backups/tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backups/tests.py b/apps/backups/tests.py index dc8a5136..85076a85 100644 --- a/apps/backups/tests.py +++ b/apps/backups/tests.py @@ -765,11 +765,12 @@ class BackupSchedulerTestCase(TestCase): settings = scheduler.get_schedule_settings() - self.assertEqual(settings['enabled'], False) + # These should match the DEFAULTS in scheduler.py + self.assertEqual(settings['enabled'], True) self.assertEqual(settings['frequency'], 'daily') self.assertEqual(settings['time'], '03:00') self.assertEqual(settings['day_of_week'], 0) - self.assertEqual(settings['retention_count'], 0) + self.assertEqual(settings['retention_count'], 3) self.assertEqual(settings['cron_expression'], '') def test_update_schedule_settings_stores_values(self): From b01eb9585ccf4120228fe449aa32aa1a4b86b0d6 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 3 Feb 2026 09:24:02 -0600 Subject: [PATCH 48/63] feat: add system notifications and update checks Real-time notifications for system events and alerts Per-user notification management and dismissal Update check on startup and every 24 hours to notify users of available versions Notification center UI component Automatic cleanup of expired notifications --- CHANGELOG.md | 6 + core/api_urls.py | 2 + core/api_views.py | 157 +++++++ core/apps.py | 49 ++ core/developer_notifications.py | 412 +++++++++++++++++ core/fixtures/developer_notifications.json | 43 ++ ...ystemnotification_notificationdismissal.py | 52 +++ core/models.py | 150 ++++++ core/serializers.py | 49 +- core/signals.py | 34 +- core/tasks.py | 224 +++++++++ core/utils.py | 73 +++ dispatcharr/settings.py | 5 + frontend/src/WebSocket.jsx | 53 +++ frontend/src/api.js | 103 +++++ .../src/components/NotificationCenter.jsx | 429 ++++++++++++++++++ frontend/src/components/Sidebar.jsx | 55 ++- frontend/src/pages/Settings.jsx | 82 ++-- frontend/src/store/notifications.jsx | 111 +++++ pyproject.toml | 1 + 20 files changed, 2041 insertions(+), 49 deletions(-) create mode 100644 core/developer_notifications.py create mode 100644 core/fixtures/developer_notifications.json create mode 100644 core/migrations/0021_systemnotification_notificationdismissal.py create mode 100644 frontend/src/components/NotificationCenter.jsx create mode 100644 frontend/src/store/notifications.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3966174a..8b3f4f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add system notifications and update checks + -Real-time notifications for system events and alerts + -Per-user notification management and dismissal + -Update check on startup and every 24 hours to notify users of available versions + -Notification center UI component + -Automatic cleanup of expired notifications - Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click. - Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view. - Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen) diff --git a/core/api_urls.py b/core/api_urls.py index 75257db1..1ac58455 100644 --- a/core/api_urls.py +++ b/core/api_urls.py @@ -6,6 +6,7 @@ from .api_views import ( UserAgentViewSet, StreamProfileViewSet, CoreSettingsViewSet, + SystemNotificationViewSet, environment, version, rehash_streams_endpoint, @@ -17,6 +18,7 @@ router = DefaultRouter() router.register(r'useragents', UserAgentViewSet, basename='useragent') router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile') router.register(r'settings', CoreSettingsViewSet, basename='coresettings') +router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification') urlpatterns = [ path('settings/env/', environment, name='token_refresh'), path('version/', version, name='version'), diff --git a/core/api_views.py b/core/api_views.py index 44001f95..20ef6249 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -3,6 +3,7 @@ import json import ipaddress import logging +from django.db import models from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.views import APIView @@ -466,3 +467,159 @@ def get_system_events(request): return Response({ 'error': 'Failed to fetch system events' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# ───────────────────────────── +# System Notifications API +# ───────────────────────────── +from .models import SystemNotification, NotificationDismissal +from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer +from django.utils import timezone as dj_timezone + + +class SystemNotificationViewSet(viewsets.ModelViewSet): + """ + API endpoint for system notifications. + Users can view active notifications and dismiss them. + Admins can create and manage notifications. + """ + serializer_class = SystemNotificationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Return notifications based on user permissions. + Filter out expired and dismissed notifications for regular users. + Evaluate conditions for developer notifications. + """ + from core.developer_notifications import evaluate_conditions + from django.core.cache import cache + + user = self.request.user + now = dj_timezone.now() + + queryset = SystemNotification.objects.filter(is_active=True) + + # Filter out expired notifications + queryset = queryset.filter( + models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now) + ) + + # Filter admin-only notifications for non-admins + if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10: + queryset = queryset.filter(admin_only=False) + + # For developer notifications, evaluate conditions + # Cache the evaluation per notification to avoid repeated condition checks + notifications_to_exclude = [] + developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER) + + for notification in developer_notifications: + action_data = notification.action_data or {} + conditions = action_data.get('condition', []) + + if not conditions: + continue + + # Cache key based on notification ID and current settings + # Cache for 5 minutes to balance freshness with performance + cache_key = f'dev_notif_condition_{notification.id}_{user.id}' + should_show = cache.get(cache_key) + + if should_show is None: + should_show = evaluate_conditions(conditions, user) + cache.set(cache_key, should_show, timeout=300) # 5 minutes + + if not should_show: + notifications_to_exclude.append(notification.id) + + if notifications_to_exclude: + queryset = queryset.exclude(id__in=notifications_to_exclude) + + return queryset + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + def list(self, request): + """ + List all active notifications for the current user. + Optionally filter by dismissed status. + """ + queryset = self.get_queryset() + + # Optional: filter out already dismissed notifications + include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true' + if not include_dismissed: + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + queryset = queryset.exclude(id__in=dismissed_ids) + + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'notifications': serializer.data, + 'count': len(serializer.data), + 'unread_count': queryset.count() + }) + + @action(detail=True, methods=['post'], url_path='dismiss') + def dismiss(self, request, pk=None): + """Dismiss a notification for the current user.""" + notification = self.get_object() + action_taken = request.data.get('action_taken', None) + + dismissal, created = NotificationDismissal.objects.get_or_create( + user=request.user, + notification=notification, + defaults={'action_taken': action_taken} + ) + + if not created and action_taken: + dismissal.action_taken = action_taken + dismissal.save() + + return Response({ + 'success': True, + 'message': 'Notification dismissed', + 'notification_key': notification.notification_key + }) + + @action(detail=False, methods=['post'], url_path='dismiss-all') + def dismiss_all(self, request): + """Dismiss all notifications for the current user.""" + notifications = self.get_queryset() + + # Get notifications not yet dismissed + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + to_dismiss = notifications.exclude(id__in=dismissed_ids) + + # Create dismissals for all + dismissals = [ + NotificationDismissal(user=request.user, notification=n) + for n in to_dismiss + ] + NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True) + + return Response({ + 'success': True, + 'dismissed_count': len(dismissals) + }) + + @action(detail=False, methods=['get'], url_path='count') + def unread_count(self, request): + """Get count of unread notifications.""" + queryset = self.get_queryset() + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + unread_count = queryset.exclude(id__in=dismissed_ids).count() + + return Response({ + 'unread_count': unread_count + }) + diff --git a/core/apps.py b/core/apps.py index 96ccfb3b..12f6d054 100644 --- a/core/apps.py +++ b/core/apps.py @@ -22,3 +22,52 @@ class CoreConfig(AppConfig): def ready(self): # Import signals to ensure they get registered import core.signals + + # Sync developer notifications and check for version updates on startup + # Only run in the main process (not in management commands or migrations) + import sys + if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False: + self._sync_developer_notifications() + self._check_version_update() + + def _sync_developer_notifications(self): + """Sync developer notifications from JSON file to database.""" + from django.db import connection + import logging + + logger = logging.getLogger(__name__) + + # Check if tables exist (avoid running during migrations) + try: + with connection.cursor() as cursor: + cursor.execute( + "SELECT 1 FROM information_schema.tables WHERE table_name = 'core_systemnotification'" + ) + if not cursor.fetchone(): + # For SQLite + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='core_systemnotification'" + ) + if not cursor.fetchone(): + return + except Exception: + # If we can't check, the table might not exist yet + pass + + try: + from core.developer_notifications import sync_developer_notifications + sync_developer_notifications() + except Exception as e: + logger.warning(f"Failed to sync developer notifications on startup: {e}") + + def _check_version_update(self): + """Check for version updates on startup.""" + import logging + + logger = logging.getLogger(__name__) + + try: + from core.tasks import check_for_version_update + check_for_version_update.delay() + except Exception as e: + logger.warning(f"Failed to check for version updates on startup: {e}") diff --git a/core/developer_notifications.py b/core/developer_notifications.py new file mode 100644 index 00000000..cf16e7a2 --- /dev/null +++ b/core/developer_notifications.py @@ -0,0 +1,412 @@ +""" +Developer Notification Sync Service + +Handles syncing developer-defined notifications from the JSON file to the database. +This ensures users receive important notifications from the development team +about recommended settings, security updates, and other announcements. + +JSON Schema (see fixtures/developer_notifications.json): + { + "id": str, # REQUIRED - Unique identifier (notification_key) + "title": str, # REQUIRED - Notification heading + "message": str, # REQUIRED - Notification body text + + "notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info') + "priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal') + "min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null) + "max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null) + "created_at": str, # OPTIONAL - ISO timestamp for tracking + "expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null) + "condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: []) + "user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all') + "action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access") + "action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set) + } + +Condition Checks: + Conditions are function names from CONDITION_CHECKS registry that evaluate + whether a notification should be shown. All conditions must pass (AND logic). + + Available conditions: + - 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere + + To add new conditions: + 1. Define a function: check_your_condition(user) -> bool + 2. Add to CONDITION_CHECKS registry + 3. Reference in JSON: "condition": ["your_condition"] + +Sync Behavior: + - Runs on startup (see apps.py) + - Runs when relevant settings change (see signals.py) + - Adds new notifications if in version range and not expired + - Updates existing notifications with latest data + - Removes notifications that are: + * No longer in JSON file + * Out of current version range + * Past expiration date + - Sends websocket event to refresh frontend + - Cache invalidated when triggering settings change +""" +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +from django.conf import settings +from django.db import models +from django.utils import timezone +from packaging import version + +from version import __version__ + +logger = logging.getLogger(__name__) + +# Path to developer notifications JSON file +NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json' + + +# ───────────────────────────── +# Condition Checks +# ───────────────────────────── +# Each condition function receives (user) and returns True if the notification should show + +def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool: + """ + Check if network access settings for a specific endpoint are insecure (allow all). + + Args: + user: The user object (unused but required for condition check signature) + endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API') + + Returns: + True if the notification should show (insecure settings detected) + """ + from core.models import CoreSettings, NETWORK_ACCESS_KEY + + try: + network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {}) + + # Empty settings are secure (defaults to local network only) + if not network_settings: + return False + + # Get the specific endpoint's allowed networks (stored as comma-separated string) + allowed_networks_str = network_settings.get(endpoint, '') + if not allowed_networks_str: + return False + + # Parse comma-separated network addresses + allowed_networks = [net.strip() for net in allowed_networks_str.split(',')] + + # Check if settings allow access from anywhere (insecure) + if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks: + return True + + return False + except Exception as e: + logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}") + return False + + +# Registry of all available condition checks +CONDITION_CHECKS: dict[str, Callable] = { + 'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'), + # Add more conditions here as needed + # 'transcode_not_configured': check_transcode_not_configured, + # 'no_backup_configured': check_no_backup_configured, +} + + +# ───────────────────────────── +# Version Utilities +# ───────────────────────────── + +def parse_version(version_str: str | None) -> version.Version | None: + """Parse a version string, returning None if invalid or empty.""" + if not version_str: + return None + try: + return version.parse(version_str) + except Exception: + return None + + +def is_version_in_range( + current_version: str, + min_version: str | None, + max_version: str | None +) -> bool: + """Check if current version is within the specified range.""" + current = parse_version(current_version) + if not current: + return True # If we can't parse version, show notification + + min_ver = parse_version(min_version) + max_ver = parse_version(max_version) + + if min_ver and current < min_ver: + return False + if max_ver and current > max_ver: + return False + + return True + + +# ───────────────────────────── +# Notification Evaluation +# ───────────────────────────── + +def evaluate_conditions(conditions: list[str] | str | None, user) -> bool: + """ + Evaluate notification conditions for a user. + All conditions must pass (AND logic). + """ + if not conditions: + return True + + # Normalize to list + if isinstance(conditions, str): + conditions = [conditions] + + for condition in conditions: + if condition not in CONDITION_CHECKS: + logger.warning(f"Unknown condition: {condition}") + continue + + try: + if not CONDITION_CHECKS[condition](user): + return False + except Exception as e: + logger.error(f"Error evaluating condition {condition}: {e}") + # On error, skip this condition (fail open) + continue + + return True + + +def should_show_notification(notification_data: dict, user) -> bool: + """ + Determine if a notification should be shown to a specific user. + Checks version range, user level, and conditions. + """ + # Check version range + if not is_version_in_range( + __version__, + notification_data.get('min_version'), + notification_data.get('max_version') + ): + return False + + # Check user level + user_level = notification_data.get('user_level', 'all') + if user_level == 'admin' and not getattr(user, 'is_superuser', False): + return False + + # Check conditions + conditions = notification_data.get('condition', []) + if not evaluate_conditions(conditions, user): + return False + + return True + + +# ───────────────────────────── +# Sync Service +# ───────────────────────────── + +def load_developer_notifications() -> list[dict]: + """Load notifications from the JSON file.""" + if not NOTIFICATIONS_FILE.exists(): + logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}") + return [] + + try: + with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('notifications', []) + except json.JSONDecodeError as e: + logger.error(f"Error parsing developer notifications JSON: {e}") + return [] + except Exception as e: + logger.error(f"Error loading developer notifications: {e}") + return [] + + +def sync_developer_notifications() -> dict[str, int]: + """ + Sync developer notifications from JSON file to database. + + - Adds new notifications that don't exist in the DB + - Removes DB notifications that are no longer in the JSON file + - Updates existing notifications if they've changed + + Returns a dict with counts of added, updated, and removed notifications. + """ + from core.models import SystemNotification + + results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0} + + notifications = load_developer_notifications() + json_notification_keys = set() + notifications_to_remove = set() # Track notifications to remove (out of range or expired) + + for notif_data in notifications: + notification_id = notif_data.get('id') + if not notification_id: + logger.warning("Notification missing 'id' field, skipping") + results['skipped'] += 1 + continue + + json_notification_keys.add(notification_id) + + # Check version constraints (only add if current version is in range) + if not is_version_in_range( + __version__, + notif_data.get('min_version'), + notif_data.get('max_version') + ): + logger.debug(f"Notification {notification_id} not in version range, marking for removal") + results['skipped'] += 1 + notifications_to_remove.add(notification_id) + continue + + # Parse expires_at if provided + expires_at = None + if notif_data.get('expires_at'): + try: + expires_at = datetime.fromisoformat( + notif_data['expires_at'].replace('Z', '+00:00') + ) + # Skip if already expired and mark for removal + if expires_at < timezone.now(): + logger.debug(f"Notification {notification_id} has expired, marking for removal") + results['skipped'] += 1 + notifications_to_remove.add(notification_id) + continue + except (ValueError, TypeError) as e: + logger.warning(f"Invalid expires_at for {notification_id}: {e}") + + # Map notification_type from JSON to model choices + type_mapping = { + 'version_update': SystemNotification.NotificationType.VERSION_UPDATE, + 'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION, + 'announcement': SystemNotification.NotificationType.ANNOUNCEMENT, + 'warning': SystemNotification.NotificationType.WARNING, + 'info': SystemNotification.NotificationType.INFO, + } + notification_type = type_mapping.get( + notif_data.get('notification_type', 'info'), + SystemNotification.NotificationType.INFO + ) + + # Map priority + priority_mapping = { + 'low': SystemNotification.Priority.LOW, + 'normal': SystemNotification.Priority.NORMAL, + 'high': SystemNotification.Priority.HIGH, + 'critical': SystemNotification.Priority.CRITICAL, + } + priority = priority_mapping.get( + notif_data.get('priority', 'normal'), + SystemNotification.Priority.NORMAL + ) + + # Prepare action_data + action_data = { + 'action_url': notif_data.get('action_url'), + 'action_text': notif_data.get('action_text'), + 'condition': notif_data.get('condition', []), + 'min_version': notif_data.get('min_version'), + 'max_version': notif_data.get('max_version'), + 'user_level': notif_data.get('user_level', 'all'), + } + + # Determine if admin-only based on user_level + admin_only = notif_data.get('user_level', 'all') == 'admin' + + # Create or update the notification + notification, created = SystemNotification.objects.update_or_create( + notification_key=notification_id, + defaults={ + 'notification_type': notification_type, + 'priority': priority, + 'source': SystemNotification.Source.DEVELOPER, + 'title': notif_data.get('title', 'Notification'), + 'message': notif_data.get('message', ''), + 'action_data': action_data, + 'is_active': True, + 'admin_only': admin_only, + 'expires_at': expires_at, + } + ) + + if created: + logger.info(f"Added developer notification: {notification_id}") + results['added'] += 1 + else: + logger.debug(f"Updated developer notification: {notification_id}") + results['updated'] += 1 + + # Remove developer notifications that are: + # - No longer in the JSON file, OR + # - Out of version range for the current version, OR + # - Expired + removed_count, _ = SystemNotification.objects.filter( + source=SystemNotification.Source.DEVELOPER + ).filter( + models.Q(notification_key__in=notifications_to_remove) | + ~models.Q(notification_key__in=json_notification_keys) + ).delete() + + if removed_count: + logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)") + results['removed'] = removed_count + + logger.info( + f"Developer notification sync complete: " + f"{results['added']} added, {results['updated']} updated, " + f"{results['removed']} removed, {results['skipped']} skipped" + ) + + # Send websocket notification to frontend to refresh notifications + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + 'type': 'notifications_cleared', + }) + logger.debug("Sent websocket notification for notifications refresh") + except Exception as e: + logger.warning(f"Failed to send websocket update: {e}") + + return results + + +def get_user_developer_notifications(user) -> list: + """ + Get all developer notifications that should be shown to a specific user. + Evaluates conditions and user_level for each notification. + """ + from core.models import SystemNotification + + # Get all active developer notifications + notifications = SystemNotification.objects.filter( + source=SystemNotification.Source.DEVELOPER, + is_active=True + ) + + # Filter by admin_only based on user + if not getattr(user, 'is_superuser', False): + notifications = notifications.filter(admin_only=False) + + # Filter by conditions + result = [] + for notification in notifications: + action_data = notification.action_data or {} + + # Evaluate conditions + conditions = action_data.get('condition', []) + if evaluate_conditions(conditions, user): + result.append(notification) + + return result diff --git a/core/fixtures/developer_notifications.json b/core/fixtures/developer_notifications.json new file mode 100644 index 00000000..0040682e --- /dev/null +++ b/core/fixtures/developer_notifications.json @@ -0,0 +1,43 @@ +{ + "_schema_documentation": { + "description": "Developer notification definitions. Each notification is evaluated at sync time.", + "fields": { + "id": "[REQUIRED] Unique identifier (notification_key in database).", + "notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'", + "priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'", + "title": "[REQUIRED] Notification title/heading.", + "message": "[REQUIRED] Detailed notification message body.", + "min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'", + "max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'", + "created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.", + "expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'", + "condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']", + "user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'", + "action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'", + "action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'" + }, + "notes": [ + "Notifications are synced from this file to database on startup and when relevant settings change", + "Out-of-range versions are automatically removed from database", + "Expired notifications are automatically removed from database", + "Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)" + ] + }, + "notifications": [ + { + "id": "network_security_m3u_epg", + "notification_type": "warning", + "priority": "high", + "title": "Network Access Security Warning", + "message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.", + "min_version": null, + "max_version": null, + "created_at": "2026-02-02T00:00:00Z", + "expires_at": null, + "condition": ["m3u_epg_network_insecure"], + "user_level": "admin", + "action_url": "/settings#network-access", + "action_text": "Review Settings" + } + ] +} \ No newline at end of file diff --git a/core/migrations/0021_systemnotification_notificationdismissal.py b/core/migrations/0021_systemnotification_notificationdismissal.py new file mode 100644 index 00000000..66eb9b18 --- /dev/null +++ b/core/migrations/0021_systemnotification_notificationdismissal.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-02-02 20:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_change_coresettings_value_to_jsonfield'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SystemNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_key', models.CharField(db_index=True, max_length=255, unique=True)), + ('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)), + ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)), + ('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)), + ('title', models.CharField(max_length=255)), + ('message', models.TextField()), + ('action_data', models.JSONField(blank=True, default=dict)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('admin_only', models.BooleanField(default=False)), + ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-priority', '-created_at'], + 'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')], + }, + ), + migrations.CreateModel( + name='NotificationDismissal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dismissed_at', models.DateTimeField(auto_now_add=True)), + ('action_taken', models.CharField(blank=True, max_length=50, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)), + ('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')), + ], + options={ + 'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')], + 'unique_together': {('user', 'notification')}, + }, + ), + ] diff --git a/core/models.py b/core/models.py index 683acb0d..a694a76b 100644 --- a/core/models.py +++ b/core/models.py @@ -370,3 +370,153 @@ class SystemEvent(models.Model): def __str__(self): return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}" + + +class SystemNotification(models.Model): + """ + Stores system notifications that users can view and dismiss. + Used for version updates, recommended settings, announcements, etc. + """ + class NotificationType(models.TextChoices): + VERSION_UPDATE = 'version_update', 'Version Update Available' + SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change' + ANNOUNCEMENT = 'announcement', 'System Announcement' + WARNING = 'warning', 'Warning' + INFO = 'info', 'Information' + + class Priority(models.TextChoices): + LOW = 'low', 'Low' + NORMAL = 'normal', 'Normal' + HIGH = 'high', 'High' + CRITICAL = 'critical', 'Critical' + + class Source(models.TextChoices): + SYSTEM = 'system', 'System Generated' + DEVELOPER = 'developer', 'Developer Notification' + + # Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer') + # This allows deduplication and targeted dismissals + notification_key = models.CharField(max_length=255, unique=True, db_index=True) + + notification_type = models.CharField( + max_length=50, + choices=NotificationType.choices, + default=NotificationType.INFO, + db_index=True + ) + priority = models.CharField( + max_length=20, + choices=Priority.choices, + default=Priority.NORMAL + ) + + # Source of the notification (system-generated vs developer-defined) + source = models.CharField( + max_length=20, + choices=Source.choices, + default=Source.SYSTEM, + db_index=True + ) + + title = models.CharField(max_length=255) + message = models.TextField() + + # Optional action data (e.g., setting key/value for recommendations, release URL for versions) + action_data = models.JSONField(default=dict, blank=True) + + # Whether this notification is currently active + is_active = models.BooleanField(default=True, db_index=True) + + # Admin-only notifications require admin privileges to view + admin_only = models.BooleanField(default=False) + + # Auto-expire after this date (null = never expires) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-priority', '-created_at'] + indexes = [ + models.Index(fields=['is_active', '-created_at']), + models.Index(fields=['notification_type', 'is_active']), + models.Index(fields=['source', 'is_active']), + ] + + def __str__(self): + return f"[{self.notification_type}] {self.title}" + + @classmethod + def create_version_notification(cls, version, release_url=None, release_notes=None): + """Create or update a version update notification. Returns (notification, created) tuple.""" + key = f"version-{version}" + notification, created = cls.objects.update_or_create( + notification_key=key, + defaults={ + 'notification_type': cls.NotificationType.VERSION_UPDATE, + 'priority': cls.Priority.HIGH, + 'title': f'Version {version} Available', + 'message': f'A new version of Dispatcharr ({version}) is available.', + 'action_data': { + 'version': version, + 'release_url': release_url, + 'release_notes': release_notes, + }, + 'is_active': True, + 'admin_only': True, + } + ) + return notification, created + + @classmethod + def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None): + """Create a setting recommendation notification. Returns (notification, created) tuple.""" + key = f"setting-{setting_key}" + notification, created = cls.objects.update_or_create( + notification_key=key, + defaults={ + 'notification_type': cls.NotificationType.SETTING_RECOMMENDATION, + 'priority': cls.Priority.NORMAL, + 'title': f'Recommended Setting: {setting_key}', + 'message': reason, + 'action_data': { + 'setting_key': setting_key, + 'recommended_value': recommended_value, + 'current_value': current_value, + }, + 'is_active': True, + 'admin_only': True, + } + ) + return notification, created + + +class NotificationDismissal(models.Model): + """ + Tracks which users have dismissed which notifications. + Allows users to dismiss notifications once without seeing them again. + """ + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='dismissed_notifications' + ) + notification = models.ForeignKey( + SystemNotification, + on_delete=models.CASCADE, + related_name='dismissals' + ) + dismissed_at = models.DateTimeField(auto_now_add=True) + + # Optional: track if user accepted/applied the recommendation + action_taken = models.CharField(max_length=50, blank=True, null=True) + + class Meta: + unique_together = ['user', 'notification'] + indexes = [ + models.Index(fields=['user', 'notification']), + ] + + def __str__(self): + return f"{self.user.username} dismissed {self.notification.notification_key}" diff --git a/core/serializers.py b/core/serializers.py index b2bd8ecc..03ba33e8 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -64,7 +64,12 @@ class CoreSettingsSerializer(serializers.ModelSerializer): } ) - return super().update(instance, validated_data) + result = super().update(instance, validated_data) + + # Note: Cache invalidation and notification sync is handled by post_save signal + # in core/signals.py to ensure it happens even if settings are updated elsewhere + + return result class ProxySettingsSerializer(serializers.Serializer): """Serializer for proxy settings stored as JSON in CoreSettings""" @@ -98,3 +103,45 @@ class ProxySettingsSerializer(serializers.Serializer): if value < 0 or value > 60: raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds") return value + + +class SystemNotificationSerializer(serializers.ModelSerializer): + """Serializer for system notifications.""" + is_dismissed = serializers.SerializerMethodField() + + class Meta: + from .models import SystemNotification + model = SystemNotification + fields = [ + 'id', + 'notification_key', + 'notification_type', + 'priority', + 'title', + 'message', + 'action_data', + 'is_active', + 'admin_only', + 'expires_at', + 'created_at', + 'is_dismissed', + 'source', + ] + read_only_fields = ['created_at'] + + def get_is_dismissed(self, obj): + """Check if the current user has dismissed this notification.""" + request = self.context.get('request') + if request and request.user.is_authenticated: + return obj.dismissals.filter(user=request.user).exists() + return False + + +class NotificationDismissalSerializer(serializers.ModelSerializer): + """Serializer for notification dismissals.""" + + class Meta: + from .models import NotificationDismissal + model = NotificationDismissal + fields = ['id', 'notification', 'dismissed_at', 'action_taken'] + read_only_fields = ['dismissed_at'] diff --git a/core/signals.py b/core/signals.py index 6844a890..bbb73dd9 100644 --- a/core/signals.py +++ b/core/signals.py @@ -1,9 +1,39 @@ -from django.db.models.signals import pre_delete +from django.db.models.signals import pre_delete, post_save from django.dispatch import receiver from django.core.exceptions import ValidationError -from .models import StreamProfile +from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY @receiver(pre_delete, sender=StreamProfile) def prevent_deletion_if_locked(sender, instance, **kwargs): if instance.locked: raise ValidationError("This profile is locked and cannot be deleted.") + +@receiver(post_save, sender=CoreSettings) +def handle_network_access_update(sender, instance, **kwargs): + """Invalidate cache and sync notifications when network access settings change.""" + if instance.key == NETWORK_ACCESS_KEY: + from django.core.cache import cache + from core.developer_notifications import sync_developer_notifications + import logging + + logger = logging.getLogger(__name__) + + # Invalidate all notification condition caches + try: + cache.delete_pattern('dev_notif_condition_*') + logger.info("Invalidated notification condition cache due to network access settings update") + except Exception as e: + logger.warning(f"Failed to delete cache pattern: {e}") + # Fallback: try to clear entire cache (if delete_pattern not supported) + try: + cache.clear() + except Exception: + pass + + # Re-sync developer notifications to re-evaluate conditions + # (websocket notification is sent by sync_developer_notifications) + try: + sync_developer_notifications() + logger.info("Re-synced developer notifications after network access settings update") + except Exception as e: + logger.error(f"Failed to sync developer notifications: {e}") diff --git a/core/tasks.py b/core/tasks.py index 78a3e0ec..44ddc68a 100644 --- a/core/tasks.py +++ b/core/tasks.py @@ -765,3 +765,227 @@ def cleanup_vod_persistent_connections(): except Exception as e: logger.error(f"Error during VOD persistent connection cleanup: {e}") + + +@shared_task +def check_for_version_update(): + """ + Check for new Dispatcharr versions on GitHub and create a notification if available. + This task should be run periodically (e.g., daily) via Celery Beat. + + For dev builds (identified by __timestamp__), checks for stable releases only. + For production builds, checks for stable releases. + + Note: Dev builds are container images from the dev branch and don't have GitHub releases. + This checks if a stable release is available so dev users know when to upgrade. + """ + import requests + from datetime import datetime, timezone + from packaging import version as pkg_version + from version import __version__, __timestamp__ + from core.models import SystemNotification + from core.utils import send_websocket_notification + + try: + is_dev_build = __timestamp__ is not None + DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'} + if is_dev_build: + # Check Docker Hub for newer dev builds + docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev" + + response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10) + + if response.status_code != 200: + logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}") + return + + dev_tag_data = response.json() + docker_last_updated = dev_tag_data.get("last_updated") + + if not docker_last_updated: + logger.warning("No last_updated timestamp found in Docker Hub response") + return + + # Parse timestamps for comparison + local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc) + docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00')) + + # Calculate difference in minutes + diff_minutes = (docker_dt - local_dt).total_seconds() / 60 + + # Threshold to account for build/push time differences + THRESHOLD_MINUTES = 10 + + if diff_minutes > THRESHOLD_MINUTES: + logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)") + + # Delete any old version update notifications (both dev and stable, in case user switched) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + if deleted_count > 0: + logger.debug(f"Deleted {deleted_count} old dev build notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + # Create notification for new dev build + notification, created = SystemNotification.objects.get_or_create( + notification_key=f'version-dev-{docker_last_updated}', + defaults={ + 'notification_type': 'version_update', + 'title': 'New Dev Build Available', + 'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)', + 'priority': 'medium', + 'action_data': { + 'current_version': __version__, + 'current_timestamp': __timestamp__, + 'docker_updated': docker_last_updated, + 'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags' + }, + 'is_active': True, + 'admin_only': True, + } + ) + + if created: + # Only send WebSocket for newly created notifications + send_websocket_notification(notification) + logger.info(f"New dev build notification created and sent via WebSocket") + else: + logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})") + + # Delete all version update notifications when up to date (both dev and stable) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} outdated dev build notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + else: + # Production build - check GitHub for stable releases + github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest" + headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS} + response = requests.get( + github_api_url, + headers=headers, + timeout=10 + ) + + if response.status_code != 200: + logger.warning(f"Failed to check for updates: HTTP {response.status_code}") + return + + release_data = response.json() + latest_version = release_data.get("tag_name", "").lstrip("v") + release_url = release_data.get("html_url", "") + + if not latest_version: + logger.warning("No version tag found in GitHub release") + return + + # Compare versions + current = pkg_version.parse(__version__) + latest = pkg_version.parse(latest_version) + if latest > current: + logger.info(f"New stable version available: {latest_version} (current: {__version__})") + + # Delete any old version update notifications (superseded by this one) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).exclude( + notification_key=f"version-{latest_version}" + ).delete()[0] + if deleted_count > 0: + logger.debug(f"Deleted {deleted_count} old version notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + # Create or update the notification for the new version + notification, created = SystemNotification.create_version_notification( + version=latest_version, + release_url=release_url, + ) + + if created: + # Only send WebSocket for newly created notifications + send_websocket_notification(notification) + logger.info(f"New version notification created and sent via WebSocket") + else: + logger.debug(f"Dispatcharr is up to date (v{__version__})") + + # Delete ALL version update notifications when up to date (no longer needed) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} outdated version notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + except requests.RequestException as e: + logger.warning(f"Network error checking for updates: {e}") + except Exception as e: + logger.error(f"Error checking for version updates: {e}") + + +def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None): + """ + Create a setting recommendation notification. + This is a helper function that can be called from anywhere in the codebase. + + Args: + setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout') + recommended_value: The recommended value for the setting + reason: Why this setting is recommended + current_value: The current value (optional) + + Returns: + The created SystemNotification instance + """ + from core.models import SystemNotification + from core.utils import send_websocket_notification + + notification, created = SystemNotification.create_setting_recommendation( + setting_key=setting_key, + recommended_value=recommended_value, + reason=reason, + current_value=current_value + ) + + # Only send via WebSocket for newly created notifications + if created: + send_websocket_notification(notification) + + return notification + diff --git a/core/utils.py b/core/utils.py index e3d6c389..5c5a0045 100644 --- a/core/utils.py +++ b/core/utils.py @@ -437,3 +437,76 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details): except Exception as e: # Don't let event logging break the main application logger.error(f"Failed to log system event {event_type}: {e}") + + +def send_websocket_notification(notification): + """ + Send a system notification to all connected WebSocket clients. + + Args: + notification: A SystemNotification model instance or dict with notification data + + Example: + from core.models import SystemNotification + notification = SystemNotification.create_version_notification('0.19.0', 'https://...') + send_websocket_notification(notification) + """ + try: + channel_layer = get_channel_layer() + + # Convert model instance to dict if needed + if hasattr(notification, 'id'): + notification_data = { + 'id': notification.id, + 'notification_key': notification.notification_key, + 'notification_type': notification.notification_type, + 'priority': notification.priority, + 'title': notification.title, + 'message': notification.message, + 'action_data': notification.action_data, + 'is_active': notification.is_active, + 'admin_only': notification.admin_only, + 'created_at': notification.created_at.isoformat() if notification.created_at else None, + } + else: + notification_data = notification + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + 'type': 'system_notification', + 'notification': notification_data, + } + } + ) + logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}") + except Exception as e: + logger.error(f"Failed to send WebSocket notification: {e}") + + +def send_notification_dismissed(notification_key): + """ + Notify all connected clients that a notification was dismissed. + Useful for syncing dismissal state across multiple browser tabs/sessions. + + Args: + notification_key: The unique key of the dismissed notification + """ + try: + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + 'type': 'notification_dismissed', + 'notification_key': notification_key, + } + } + ) + except Exception as e: + logger.error(f"Failed to send notification dismissed event: {e}") + diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index b65ee2d0..7af6e939 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -236,6 +236,11 @@ CELERY_BEAT_SCHEDULE = { "task": "apps.channels.tasks.maintain_recurring_recordings", "schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead }, + # Check for version updates daily + "check-version-updates": { + "task": "core.tasks.check_for_version_update", + "schedule": 86400.0, # Once every 24 hours + }, } MEDIA_ROOT = BASE_DIR / "media" diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 034418f6..2d6cc0d9 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -845,6 +845,59 @@ export const WebsocketProvider = ({ children }) => { break; } + case 'system_notification': { + // Handle real-time system notifications (version updates, setting recommendations, etc.) + const notificationData = parsedEvent.data.notification; + if (notificationData) { + // Import and update the notifications store + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore + .getState() + .addNotification(notificationData); + + // Show a toast notification for high priority items + if ( + notificationData.priority === 'high' || + notificationData.priority === 'critical' + ) { + const color = + notificationData.notification_type === 'version_update' + ? 'green' + : notificationData.notification_type === 'warning' + ? 'orange' + : 'blue'; + + notifications.show({ + title: notificationData.title, + message: notificationData.message, + color, + autoClose: 10000, + }); + } + } + break; + } + + case 'notification_dismissed': { + // Handle notification dismissed from another session + const { notification_key } = parsedEvent.data; + if (notification_key) { + const { default: useNotificationsStore } = + await import('./store/notifications'); + useNotificationsStore + .getState() + .dismissNotification(notification_key); + } + break; + } + + case 'notifications_cleared': { + // Handle bulk notification clearing (e.g., when version is updated) + API.getNotifications(); + break; + } + default: console.error( `Unknown websocket event type: ${parsedEvent.data?.type}` diff --git a/frontend/src/api.js b/frontend/src/api.js index a831ad22..676e97d8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2890,4 +2890,107 @@ export default class API { errorNotification('Failed to retrieve system events', e); } } + + // ───────────────────────────── + // System Notifications + // ───────────────────────────── + + /** + * Get all active notifications for the current user + * @param {boolean} includeDismissed - Whether to include already dismissed notifications + */ + static async getNotifications(includeDismissed = false) { + try { + const params = new URLSearchParams(); + if (includeDismissed) { + params.append('include_dismissed', 'true'); + } + const response = await request( + `${host}/api/core/notifications/?${params.toString()}` + ); + + // Update the store with fetched notifications + const { default: useNotificationsStore } = await import( + './store/notifications' + ); + useNotificationsStore.getState().setNotifications(response.notifications); + + return response; + } catch (e) { + errorNotification('Failed to retrieve notifications', e); + } + } + + // Get unread notification count + static async getNotificationCount() { + try { + const response = await request(`${host}/api/core/notifications/count/`); + + // Update the store with the count + const { default: useNotificationsStore } = await import( + './store/notifications' + ); + useNotificationsStore.getState().setUnreadCount(response.unread_count); + + return response; + } catch (e) { + // Silent fail for count - not critical + console.error('Failed to get notification count:', e); + return { unread_count: 0 }; + } + } + + /** + * Dismiss a specific notification + * @param {number} notificationId - The notification ID to dismiss + * @param {string} actionTaken - Optional action taken (e.g., 'applied', 'ignored') + */ + static async dismissNotification(notificationId, actionTaken = null) { + try { + const body = {}; + if (actionTaken) { + body.action_taken = actionTaken; + } + + const response = await request( + `${host}/api/core/notifications/${notificationId}/dismiss/`, + { + method: 'POST', + body, + } + ); + + // Update the store + const { default: useNotificationsStore } = await import( + './store/notifications' + ); + useNotificationsStore.getState().dismissNotification(response.notification_key); + + return response; + } catch (e) { + errorNotification('Failed to dismiss notification', e); + } + } + + // Dismiss all notifications + static async dismissAllNotifications() { + try { + const response = await request( + `${host}/api/core/notifications/dismiss-all/`, + { + method: 'POST', + } + ); + + // Update the store + const { default: useNotificationsStore } = await import( + './store/notifications' + ); + useNotificationsStore.getState().dismissAllNotifications(); + + return response; + } catch (e) { + errorNotification('Failed to dismiss all notifications', e); + } + } } diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx new file mode 100644 index 00000000..f7da56ad --- /dev/null +++ b/frontend/src/components/NotificationCenter.jsx @@ -0,0 +1,429 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Divider, + Group, + Indicator, + Popover, + ScrollArea, + Stack, + Text, + ThemeIcon, + Tooltip, + useMantineTheme, +} from '@mantine/core'; +import { + Bell, + Check, + CheckCheck, + Download, + ExternalLink, + Info, + Settings, + AlertTriangle, + Megaphone, + X, + Eye, + EyeOff, + ArrowRight, +} from 'lucide-react'; +import useNotificationsStore from '../store/notifications'; +import API from '../api'; + +// Get icon for notification type +const getNotificationIcon = (type) => { + switch (type) { + case 'version_update': + return ; + case 'setting_recommendation': + return ; + case 'announcement': + return ; + case 'warning': + return ; + case 'info': + default: + return ; + } +}; + +// Get color for notification priority +const getPriorityColor = (priority) => { + switch (priority) { + case 'critical': + return 'red'; + case 'high': + return 'orange'; + case 'normal': + return 'blue'; + case 'low': + default: + return 'gray'; + } +}; + +// Get color for notification type +const getTypeColor = (type) => { + switch (type) { + case 'version_update': + return 'green'; + case 'setting_recommendation': + return 'blue'; + case 'announcement': + return 'violet'; + case 'warning': + return 'orange'; + case 'info': + default: + return 'gray'; + } +}; + +// Individual notification item component +const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => { + const theme = useMantineTheme(); + const navigate = useNavigate(); + const typeColor = getTypeColor(notification.notification_type); + const priorityColor = getPriorityColor(notification.priority); + const isDismissed = notification.is_dismissed; + + const handleDismiss = (e) => { + e.stopPropagation(); + onDismiss(notification.id, 'dismissed'); + }; + + const handleAction = () => { + // Handle action_url from action_data + const actionUrl = notification.action_data?.action_url; + const releaseUrl = notification.action_data?.release_url; + + if (actionUrl) { + // Internal navigation + onClose(); // Close the popover + navigate(actionUrl); + } else if (releaseUrl) { + // External link + window.open(releaseUrl, '_blank'); + } + + if (onAction) { + onAction(notification); + } + }; + + return ( + + {/* Dismiss button for non-setting notifications (only if not already dismissed) */} + {notification.notification_type !== 'setting_recommendation' && + !isDismissed && ( + + + + )} + + + + {getNotificationIcon(notification.notification_type)} + + + + + {notification.title} + + {isDismissed && ( + + Dismissed + + )} + {notification.priority === 'high' || + notification.priority === 'critical' ? ( + + {notification.priority} + + ) : null} + + + {notification.message} + + + {/* Action buttons for specific notification types */} + {notification.notification_type === 'version_update' && + notification.action_data?.release_url && ( + + )} + + {/* Generic action button for notifications with action_url/action_text */} + {notification.action_data?.action_url && + notification.action_data?.action_text && ( + + )} + + {notification.notification_type === 'setting_recommendation' && + !notification.action_data?.action_url && ( + + + + + )} + + + + + {new Date(notification.created_at).toLocaleDateString()} + + + ); +}; + +// Main notification center component with bell icon and popover +const NotificationCenter = ({ onSettingAction }) => { + const [opened, setOpened] = useState(false); + const [showDismissed, setShowDismissed] = useState(false); + + const notifications = useNotificationsStore((s) => s.notifications); + const unreadCount = useNotificationsStore((s) => s.unreadCount); + const getUnreadNotifications = useNotificationsStore( + (s) => s.getUnreadNotifications + ); + + // Fetch notifications on mount and periodically + const fetchNotifications = useCallback(async () => { + try { + await API.getNotifications(showDismissed); + } catch (error) { + console.error('Failed to fetch notifications:', error); + } + }, [showDismissed]); + + useEffect(() => { + fetchNotifications(); + + // Refresh notifications every 5 minutes + const interval = setInterval(fetchNotifications, 5 * 60 * 1000); + return () => clearInterval(interval); + }, [fetchNotifications]); + + const handleDismiss = async (notificationId, actionTaken = null) => { + try { + await API.dismissNotification(notificationId, actionTaken); + } catch (error) { + console.error('Failed to dismiss notification:', error); + } + }; + + const handleDismissAll = async () => { + try { + await API.dismissAllNotifications(); + } catch (error) { + console.error('Failed to dismiss all notifications:', error); + } + }; + + const handleAction = (notification) => { + if ( + notification.notification_type === 'setting_recommendation' && + onSettingAction + ) { + onSettingAction(notification); + } + }; + + const unreadNotifications = getUnreadNotifications(); + const displayedNotifications = showDismissed + ? notifications + : unreadNotifications; + + return ( + + + 9 ? '9+' : unreadCount} + disabled={unreadCount === 0} + offset={4} + processing={unreadCount > 0} + > + setOpened((o) => !o)} + aria-label="Notifications" + > + + + + + + + {/* Header */} + + + + Notifications + + {unreadCount > 0 && ( + + {unreadCount} new + + )} + + + + setShowDismissed((prev) => !prev)} + > + {showDismissed ? : } + + + {unreadCount > 0 && ( + + + + + + )} + + + + + + {/* Notification list */} + + {displayedNotifications.length === 0 ? ( + + + + + + {showDismissed + ? 'No dismissed notifications' + : 'All caught up!'} + + + {showDismissed + ? 'Dismissed notifications appear here' + : 'No new notifications'} + + + ) : ( + + {displayedNotifications.map((notification) => ( + setOpened(false)} + /> + ))} + + )} + + + {/* Footer with info text */} + {!showDismissed && + notifications.length > unreadNotifications.length && ( + <> + + + + {notifications.length - unreadNotifications.length} dismissed + notification + {notifications.length - unreadNotifications.length !== 1 + ? 's' + : ''} + + + + )} + + + ); +}; + +export default NotificationCenter; diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 435509e0..6c997cfe 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -36,6 +36,7 @@ import useSettingsStore from '../store/settings'; import useAuthStore from '../store/auth'; import { USER_LEVELS } from '../constants'; import UserForm from './forms/User'; +import NotificationCenter from './NotificationCenter'; const NavLink = ({ item, isActive, collapsed }) => { return ( @@ -232,7 +233,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }} > {isAuthenticated && ( - + {!collapsed && ( { /> )} - {!collapsed && authUser && ( - setUserFormOpen(true)}> - {authUser.first_name || authUser.username} - - + + + setUserFormOpen(true)}> + {authUser.first_name || authUser.username} + + )} - + {collapsed && ( + + + + )} + )}
- {/* Version is always shown when sidebar is expanded, regardless of auth status */} + {/* Version and Notification */} {!collapsed && ( - - v{appVersion?.version || '0.0.0'} - {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} - + + + v{appVersion?.version || '0.0.0'} + {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''} + + {isAuthenticated && } + + )} + {collapsed && isAuthenticated && ( + + + )} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 4ce519a3..a2ee0545 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,4 +1,5 @@ -import React, { Suspense, useState } from 'react'; +import React, { Suspense, useState, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { Accordion, AccordionControl, @@ -7,48 +8,64 @@ import { Box, Center, Text, - Loader + Loader, } from '@mantine/core'; -const UserAgentsTable = React.lazy(() => - import('../components/tables/UserAgentsTable.jsx')); -const StreamProfilesTable = React.lazy(() => - import('../components/tables/StreamProfilesTable.jsx')); -const BackupManager = React.lazy(() => - import('../components/backups/BackupManager.jsx')); +const UserAgentsTable = React.lazy( + () => import('../components/tables/UserAgentsTable.jsx') +); +const StreamProfilesTable = React.lazy( + () => import('../components/tables/StreamProfilesTable.jsx') +); +const BackupManager = React.lazy( + () => import('../components/backups/BackupManager.jsx') +); import useAuthStore from '../store/auth'; import { USER_LEVELS } from '../constants'; import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; -const NetworkAccessForm = React.lazy(() => - import('../components/forms/settings/NetworkAccessForm.jsx')); -const ProxySettingsForm = React.lazy(() => - import('../components/forms/settings/ProxySettingsForm.jsx')); -const StreamSettingsForm = React.lazy(() => - import('../components/forms/settings/StreamSettingsForm.jsx')); -const DvrSettingsForm = React.lazy(() => - import('../components/forms/settings/DvrSettingsForm.jsx')); -const SystemSettingsForm = React.lazy(() => - import('../components/forms/settings/SystemSettingsForm.jsx')); +const NetworkAccessForm = React.lazy( + () => import('../components/forms/settings/NetworkAccessForm.jsx') +); +const ProxySettingsForm = React.lazy( + () => import('../components/forms/settings/ProxySettingsForm.jsx') +); +const StreamSettingsForm = React.lazy( + () => import('../components/forms/settings/StreamSettingsForm.jsx') +); +const DvrSettingsForm = React.lazy( + () => import('../components/forms/settings/DvrSettingsForm.jsx') +); +const SystemSettingsForm = React.lazy( + () => import('../components/forms/settings/SystemSettingsForm.jsx') +); const SettingsPage = () => { const authUser = useAuthStore((s) => s.user); + const location = useLocation(); - const [accordianValue, setAccordianValue] = useState(null); + const [accordianValue, setAccordianValue] = useState('ui-settings'); + + // Handle hash navigation to open specific accordion + useEffect(() => { + const hash = location.hash.replace('#', ''); + if (hash) { + setAccordianValue(hash); + } + }, [location.hash]); return (
UI Settings - + @@ -60,7 +77,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'dvr-settings'} + /> @@ -72,7 +90,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'stream-settings'} + /> @@ -84,7 +103,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'system-settings'} + /> @@ -96,7 +116,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'user-agents'} + /> @@ -108,7 +129,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'stream-profiles'} + /> @@ -127,7 +149,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'network-access'} + /> @@ -141,7 +164,8 @@ const SettingsPage = () => { }> + active={accordianValue === 'proxy-settings'} + /> diff --git a/frontend/src/store/notifications.jsx b/frontend/src/store/notifications.jsx new file mode 100644 index 00000000..438b0cbc --- /dev/null +++ b/frontend/src/store/notifications.jsx @@ -0,0 +1,111 @@ +import { create } from 'zustand'; + +// Store for managing system notifications (version updates, recommended settings, announcements) +const useNotificationsStore = create((set, get) => ({ + notifications: [], + unreadCount: 0, + isLoading: false, + error: null, + lastFetched: null, + + // Set notifications directly (used by API layer) + setNotifications: (notifications) => { + const unreadCount = notifications.filter((n) => !n.is_dismissed).length; + set({ + notifications, + unreadCount, + lastFetched: new Date().toISOString(), + }); + }, + + // Add a new notification (e.g., from WebSocket) + addNotification: (notification) => { + set((state) => { + const exists = state.notifications.some( + (n) => n.notification_key === notification.notification_key + ); + if (exists) { + // Update existing notification + return { + notifications: state.notifications.map((n) => + n.notification_key === notification.notification_key + ? { ...n, ...notification } + : n + ), + }; + } + // Add new notification + const newNotifications = [notification, ...state.notifications]; + return { + notifications: newNotifications, + unreadCount: newNotifications.filter((n) => !n.is_dismissed).length, + }; + }); + }, + + // Mark a notification as dismissed locally + dismissNotification: (notificationKey) => { + set((state) => { + const notifications = state.notifications.map((n) => + n.notification_key === notificationKey + ? { ...n, is_dismissed: true } + : n + ); + return { + notifications, + unreadCount: notifications.filter((n) => !n.is_dismissed).length, + }; + }); + }, + + // Mark all notifications as dismissed locally + dismissAllNotifications: () => { + set((state) => ({ + notifications: state.notifications.map((n) => ({ + ...n, + is_dismissed: true, + })), + unreadCount: 0, + })); + }, + + // Remove a notification from the store + removeNotification: (notificationKey) => { + set((state) => { + const notifications = state.notifications.filter( + (n) => n.notification_key !== notificationKey + ); + return { + notifications, + unreadCount: notifications.filter((n) => !n.is_dismissed).length, + }; + }); + }, + + // Update unread count + setUnreadCount: (count) => { + set({ unreadCount: count }); + }, + + // Set loading state + setLoading: (isLoading) => { + set({ isLoading }); + }, + + // Set error state + setError: (error) => { + set({ error }); + }, + + // Get notifications by type + getNotificationsByType: (type) => { + return get().notifications.filter((n) => n.notification_type === type); + }, + + // Get unread notifications only + getUnreadNotifications: () => { + return get().notifications.filter((n) => !n.is_dismissed); + }, +})); + +export default useNotificationsStore; diff --git a/pyproject.toml b/pyproject.toml index 9400a55d..82961ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "django-filter", "django-celery-beat", "lxml==6.0.2", + "packaging", ] [build-system] From 49a5feb6713c0fc4c3f0c0ba7ffce20985493397 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 3 Feb 2026 09:41:40 -0600 Subject: [PATCH 49/63] test: refactor SettingsPage tests to use MemoryRouter for routing context --- .../src/pages/__tests__/Settings.test.jsx | 136 +++++++++++++----- 1 file changed, 98 insertions(+), 38 deletions(-) diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx index 6a254326..3e32afca 100644 --- a/frontend/src/pages/__tests__/Settings.test.jsx +++ b/frontend/src/pages/__tests__/Settings.test.jsx @@ -1,9 +1,6 @@ -import { - render, - screen, - waitFor, -} from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MemoryRouter } from 'react-router-dom'; import SettingsPage from '../Settings'; import useAuthStore from '../../store/auth'; import { USER_LEVELS } from '../../constants'; @@ -12,38 +9,76 @@ import userEvent from '@testing-library/user-event'; // Mock all dependencies vi.mock('../../store/auth'); vi.mock('../../components/tables/UserAgentsTable', () => ({ - default: ({ active }) =>
UserAgentsTable {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ UserAgentsTable {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/tables/StreamProfilesTable', () => ({ - default: ({ active }) =>
StreamProfilesTable {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ StreamProfilesTable {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/backups/BackupManager', () => ({ - default: ({ active }) =>
BackupManager {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ BackupManager {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/forms/settings/UiSettingsForm', () => ({ - default: ({ active }) =>
UiSettingsForm {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ UiSettingsForm {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({ - default: ({ active }) =>
NetworkAccessForm {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ NetworkAccessForm {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({ - default: ({ active }) =>
ProxySettingsForm {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ ProxySettingsForm {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({ - default: ({ active }) =>
StreamSettingsForm {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ StreamSettingsForm {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({ - default: ({ active }) =>
DvrSettingsForm {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ DvrSettingsForm {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({ - default: ({ active }) =>
SystemSettingsForm {active ? 'active' : 'inactive'}
, + default: ({ active }) => ( +
+ SystemSettingsForm {active ? 'active' : 'inactive'} +
+ ), })); vi.mock('../../components/ErrorBoundary', () => ({ default: ({ children }) =>
{children}
, })); vi.mock('@mantine/core', async () => { - const accordionComponent = ({ children, onChange, defaultValue }) =>
{children}
; + const accordionComponent = ({ children, onChange, defaultValue }) => ( +
{children}
+ ); accordionComponent.Item = ({ children, value }) => (
{children}
); @@ -66,6 +101,15 @@ vi.mock('@mantine/core', async () => { }; }); +// Helper function to render with router context +const renderWithRouter = ( + component, + { initialEntries = ['/settings'] } = {} +) => { + return render( + {component} + ); +}; describe('SettingsPage', () => { beforeEach(() => { @@ -81,26 +125,28 @@ describe('SettingsPage', () => { }); it('renders the settings page', () => { - render(); + renderWithRouter(); expect(screen.getByTestId('accordion')).toBeInTheDocument(); }); it('renders UI Settings accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-ui-settings')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-ui-settings') + ).toBeInTheDocument(); expect(screen.getByText('UI Settings')).toBeInTheDocument(); }); it('opens UI Settings panel by default', () => { - render(); + renderWithRouter(); expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument(); }); it('does not render admin-only sections for regular users', () => { - render(); + renderWithRouter(); expect(screen.queryByText('DVR')).not.toBeInTheDocument(); expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument(); @@ -122,7 +168,7 @@ describe('SettingsPage', () => { }); it('renders all accordion items for admin', async () => { - render(); + renderWithRouter(); expect(screen.getByText('UI Settings')).toBeInTheDocument(); @@ -139,49 +185,63 @@ describe('SettingsPage', () => { }); it('renders DVR settings accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-dvr-settings')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-dvr-settings') + ).toBeInTheDocument(); }); it('renders Stream Settings accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-stream-settings')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-stream-settings') + ).toBeInTheDocument(); }); it('renders System Settings accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-system-settings')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-system-settings') + ).toBeInTheDocument(); }); it('renders User-Agents accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-user-agents')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-user-agents') + ).toBeInTheDocument(); }); it('renders Stream Profiles accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-stream-profiles')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-stream-profiles') + ).toBeInTheDocument(); }); it('renders Network Access accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-network-access')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-network-access') + ).toBeInTheDocument(); }); it('renders Proxy Settings accordion item', () => { - render(); + renderWithRouter(); - expect(screen.getByTestId('accordion-item-proxy-settings')).toBeInTheDocument(); + expect( + screen.getByTestId('accordion-item-proxy-settings') + ).toBeInTheDocument(); }); it('renders Backup & Restore accordion item', () => { - render(); + renderWithRouter(); expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument(); }); @@ -197,7 +257,7 @@ describe('SettingsPage', () => { it('opens DVR settings when clicked', async () => { const user = userEvent.setup(); - render(); + renderWithRouter(); const streamSettingsButton = screen.getByText('DVR'); await user.click(streamSettingsButton); @@ -205,4 +265,4 @@ describe('SettingsPage', () => { await screen.findByTestId('dvr-settings-form'); }); }); -}); \ No newline at end of file +}); From 92f59b1a9e87e040a78680ee42459de4c6241331 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 3 Feb 2026 10:03:37 -0600 Subject: [PATCH 50/63] changelog: Update changelog for modular PR --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3f4f3c..da54fb68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. +- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues. - Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. - Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added @@ -46,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). - EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) From 7b83a90f01594d68285894bed4ced11168baf033 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 3 Feb 2026 10:35:50 -0600 Subject: [PATCH 51/63] changelog: Update changelog for auto-match PR --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da54fb68..24b0dfb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `useVideoStore` tests for video player state management - `useWarningsStore` tests for warning suppression functionality - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) +- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen) ### Changed From 027d114299dfe154b05a5501d32c3152bcca3ed3 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Tue, 3 Feb 2026 12:16:31 -0600 Subject: [PATCH 52/63] Enhancement: XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors. --- CHANGELOG.md | 1 + apps/m3u/tasks.py | 48 +++++++++++++++++++---------------------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b0dfb0..cb77e53d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors. - M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. - Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues. - Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index d0d6978e..f929a208 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -1326,36 +1326,14 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta ) as xc_client: logger.info(f"XCClient instance created successfully") - # Authenticate with detailed error handling + # Queue async profile refresh task to run in background + # This prevents any delay in the main refresh process try: - logger.debug(f"Authenticating with XC server {server_url}") - auth_result = xc_client.authenticate() - logger.debug(f"Authentication response: {auth_result}") - - # Queue async profile refresh task to run in background - # This prevents any delay in the main refresh process - try: - logger.info(f"Queueing background profile refresh for account {account.name}") - refresh_account_profiles.delay(account.id) - except Exception as e: - logger.warning(f"Failed to queue profile refresh task: {str(e)}") - # Don't fail the main refresh if profile refresh can't be queued - + logger.info(f"Queueing background profile refresh for account {account.name}") + refresh_account_profiles.delay(account.id) except Exception as e: - error_msg = f"Failed to authenticate with XC server: {str(e)}" - logger.error(error_msg) - account.status = M3UAccount.Status.ERROR - account.last_message = error_msg - account.save(update_fields=["status", "last_message"]) - send_m3u_update( - account_id, - "processing_groups", - 100, - status="error", - error=error_msg, - ) - release_task_lock("refresh_m3u_account_groups", account_id) - return error_msg, None + logger.warning(f"Failed to queue profile refresh task: {str(e)}") + # Don't fail the main refresh if profile refresh can't be queued # Get categories with detailed error handling try: @@ -1395,7 +1373,19 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_sta "xc_id": cat_id, } except Exception as e: - error_msg = f"Failed to get categories from XC server: {str(e)}" + # Determine if this is an authentication error or category retrieval error + error_str = str(e).lower() + # Check for authentication-related keywords or HTTP status codes commonly used for auth failures + is_auth_error = any(keyword in error_str for keyword in [ + 'auth', 'credential', 'login', 'unauthorized', 'forbidden', + '401', '403', '512', '513' # HTTP status codes: 401 Unauthorized, 403 Forbidden, 512-513 (non-standard auth failure) + ]) + + if is_auth_error: + error_msg = f"Failed to authenticate with XC server: {str(e)}" + else: + error_msg = f"Failed to get categories from XC server: {str(e)}" + logger.error(error_msg) account.status = M3UAccount.Status.ERROR account.last_message = error_msg From e217960500be87238c82053a66b3eb0a25b46318 Mon Sep 17 00:00:00 2001 From: None Date: Tue, 3 Feb 2026 21:42:06 -0600 Subject: [PATCH 53/63] feat: Add Redis authentication support for modular deployment Add support for authentication when connecting to external Redis instances in modular deployment mode. This enables secure Redis deployments using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). Changes: - Add REDIS_PASSWORD and REDIS_USER environment variables - Implement URL encoding for special characters in passwords - Update all Redis connection points to support auth - Add comprehensive documentation and examples to docker-compose.yml - Maintains full backward compatibility (empty defaults = no auth) All authentication mechanisms have been fully tested including pasword-only authentication, Redis 6+ ACL authentication with username + password, volume-mounted configuration files, and special character handling. Existing deployments are not effected, authentication support is entirely opt-in using docker-compose. Also, acknowledging the fact that the docker-compose file for modular deployments has been getting out of hand with auth support, the docker-compose.yml file was re-formatted for better visibility in configuration. This seemed like a better way to go than mandating a .env file. --- apps/proxy/ts_proxy/server.py | 4 + apps/proxy/vod_proxy/connection_manager.py | 11 ++- apps/proxy/vod_proxy/views.py | 11 ++- core/utils.py | 8 ++ core/views.py | 10 +- dispatcharr/persistent_lock.py | 10 +- dispatcharr/settings.py | 27 +++++- docker/docker-compose.yml | 108 ++++++++++++++++++--- docker/entrypoint.sh | 4 +- scripts/wait_for_redis.py | 8 +- 10 files changed, 176 insertions(+), 25 deletions(-) diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index db5b3d57..df51744d 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -149,11 +149,15 @@ class ProxyServer: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) pubsub_client = redis.Redis( host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=60, socket_connect_timeout=10, socket_keepalive=True, diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index ec0bffa5..aafc75cd 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -101,7 +101,16 @@ class PersistentVODConnection: redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{self.session_id}" stored_length = r.get(content_length_key) if stored_length: diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 2ec95cc3..948604d6 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -333,7 +333,16 @@ class VODStreamView(View): redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{session_id}" r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") diff --git a/core/utils.py b/core/utils.py index 5c5a0045..c0e87a42 100644 --- a/core/utils.py +++ b/core/utils.py @@ -55,6 +55,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5) @@ -68,6 +70,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=socket_timeout, socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, @@ -148,6 +152,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings but without socket timeouts for PubSub # Important: socket_timeout is None for PubSub operations @@ -161,6 +167,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=None, # Critical: No timeout for PubSub operations socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, diff --git a/core/views.py b/core/views.py index 5806d63c..082cccc3 100644 --- a/core/views.py +++ b/core/views.py @@ -40,7 +40,15 @@ def stream_view(request, channel_uuid): redis_host = getattr(settings, "REDIS_HOST", "localhost") redis_port = int(getattr(settings, "REDIS_PORT", 6379)) redis_db = int(getattr(settings, "REDIS_DB", "0")) - redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = getattr(settings, "REDIS_PASSWORD", "") + redis_user = getattr(settings, "REDIS_USER", "") + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(uuid=channel_uuid) diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py index 27d480be..2ed72bf4 100644 --- a/dispatcharr/persistent_lock.py +++ b/dispatcharr/persistent_lock.py @@ -78,7 +78,15 @@ if __name__ == "__main__": redis_host = os.environ.get("REDIS_HOST", "localhost") redis_port = int(os.environ.get("REDIS_PORT", 6379)) redis_db = int(os.environ.get("REDIS_DB", 0)) - client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = os.environ.get("REDIS_PASSWORD", "") + redis_user = os.environ.get("REDIS_USER", "") + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) lock = PersistentLock(client, "lock:example_account", lock_timeout=120) if lock.acquire(): diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 7af6e939..f0ef75a0 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,6 +1,7 @@ import os from pathlib import Path from datetime import timedelta +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent @@ -8,6 +9,8 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_DB = os.environ.get("REDIS_DB", "0") +REDIS_USER = os.environ.get("REDIS_USER", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": @@ -119,7 +122,12 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(REDIS_HOST, REDIS_PORT, REDIS_DB)], # Ensure Redis is running + "hosts": ["redis://{redis_auth}{host}:{port}/{db}".format( + redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "", + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB + )], # URL format supports authentication }, }, } @@ -197,8 +205,19 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" -# Build default Redis URL from components for Celery -_default_redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# Build default Redis URL from components for Celery with optional authentication +# Build auth string conditionally with URL encoding for special characters +if REDIS_PASSWORD: + encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + encoded_user = quote_plus(REDIS_USER) + redis_auth = f"{encoded_user}:{encoded_password}@" + else: + redis_auth = f":{encoded_password}@" +else: + redis_auth = "" + +_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url) CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) @@ -269,7 +288,7 @@ SIMPLE_JWT = { } # Redis connection settings -REDIS_URL = os.environ.get("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") +REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c8e2d53e..89585fc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,11 @@ +# Dispatcharr - Modular Deployment Configuration +# This compose file runs Dispatcharr in modular mode with separate containers +# for web, celery workers, PostgreSQL database, and Redis cache. + services: + # ============================================================================ + # Web Service + # ============================================================================ web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web @@ -9,44 +16,66 @@ services: depends_on: - db - redis + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info + # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) - # that lack support for newer baseline CPU features + # that lack support for newer baseline CPU features: #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) - # Negative values require cap_add: SYS_NICE (uncomment below) + # Negative values require cap_add: SYS_NICE (see below) #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) - # + + # --- Advanced Configuration --- # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - # Optional for hardware acceleration + + # --- Hardware Acceleration (Optional) --- + # Uncomment for GPU access (transcoding acceleration) #group_add: # - video - # #- render # Uncomment if your GPU requires it + # #- render # Uncomment if your GPU requires it #devices: # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) - # Uncomment the following lines for NVIDIA GPU support - # NVidia GPU support (requires NVIDIA Container Toolkit) + + # NVIDIA GPU Support (requires NVIDIA Container Toolkit) #deploy: # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + # ============================================================================ + # Celery Service - Background task worker + # ============================================================================ celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery @@ -58,24 +87,47 @@ services: - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" + entrypoint: ["/app/docker/entrypoint.celery.sh"] + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info - #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19) + + # Process Priority Configuration (Optional) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + + # Django Configuration - DJANGO_SETTINGS_MODULE=dispatcharr.settings - PYTHONUNBUFFERED=1 + + # --- Advanced Configuration --- # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - entrypoint: ["/app/docker/entrypoint.celery.sh"] + # ============================================================================ + # PostgreSQL + # ============================================================================ db: image: postgres:17 container_name: dispatcharr_db @@ -93,14 +145,42 @@ services: timeout: 5s retries: 5 + # ============================================================================ + # Redis + # ============================================================================ redis: image: redis:latest container_name: dispatcharr_redis + + # --- Authentication Configuration (Optional) --- + # By default, Redis runs without authentication. + # Choose ONE of the following options if authentication is required: + + # Option 1: Password-only authentication (Redis <6 or default user) + #command: ["redis-server", "--requirepass", "your_strong_redis_password"] + + # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration) + #command: ["redis-server", "/etc/redis/redis.conf"] + #volumes: + # - ./redis.conf:/etc/redis/redis.conf:ro + + # --- Health Check Configuration --- healthcheck: + # Default: No authentication test: ["CMD", "redis-cli", "ping"] + + # If using Option 1 (password-only), uncomment this instead: + #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"] + + # If using Option 2 (Redis 6+ ACL), uncomment this instead: + #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"] + interval: 5s timeout: 5s retries: 5 +# ============================================================================== +# Volumes +# ============================================================================== volumes: postgres_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ead6aadd..8ea47318 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -38,6 +38,8 @@ export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" export REDIS_HOST=${REDIS_HOST:-localhost} export REDIS_PORT=${REDIS_PORT:-6379} export REDIS_DB=${REDIS_DB:-0} +export REDIS_PASSWORD=${REDIS_PASSWORD:-} +export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' @@ -104,7 +106,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 38737728..0d278150 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -12,7 +12,7 @@ import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_interval=2): +def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2): """Wait for Redis to become available""" redis_client = None retry_count = 0 @@ -25,6 +25,8 @@ def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_inte host=host, port=port, db=db, + password=password if password else None, + username=username if username else None, socket_timeout=2, socket_connect_timeout=2 ) @@ -50,12 +52,14 @@ if __name__ == "__main__": host = os.environ.get('REDIS_HOST', 'localhost') port = int(os.environ.get('REDIS_PORT', 6379)) db = int(os.environ.get('REDIS_DB', 0)) + password = os.environ.get('REDIS_PASSWORD', '') + username = os.environ.get('REDIS_USER', '') max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30)) retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2)) logger.info(f"Starting Redis availability check at {host}:{port}/{db}") - if wait_for_redis(host, port, db, max_retries, retry_interval): + if wait_for_redis(host, port, db, password, username, max_retries, retry_interval): sys.exit(0) else: sys.exit(1) From e26c1908c5ac8c49d324c7084dae7b6f9efe9a36 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 4 Feb 2026 16:56:11 -0600 Subject: [PATCH 54/63] Bug Fix: Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention. --- CHANGELOG.md | 1 + apps/backups/apps.py | 30 ++++++++++++++++++++++++++++++ dispatcharr/settings.py | 1 + 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb77e53d..f711d67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention. - Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). diff --git a/apps/backups/apps.py b/apps/backups/apps.py index ee644149..d4bbb973 100644 --- a/apps/backups/apps.py +++ b/apps/backups/apps.py @@ -1,7 +1,37 @@ +import logging +import sys + from django.apps import AppConfig +logger = logging.getLogger(__name__) + class BackupsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.backups" verbose_name = "Backups" + + def ready(self): + """Initialize backup scheduler on app startup.""" + # Only run in the main process (not in workers, beat, or management commands) + if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False: + self._sync_backup_scheduler() + + def _sync_backup_scheduler(self): + """Sync backup scheduler task to database.""" + # Import here to avoid circular imports + from core.models import CoreSettings + from .scheduler import _sync_periodic_task, DEFAULTS + try: + # Ensure settings exist with defaults if this is a new install + CoreSettings.objects.get_or_create( + key="backup_settings", + defaults={"name": "Backup Settings", "value": DEFAULTS.copy()} + ) + + # Always sync the periodic task (handles new installs, updates, or missing tasks) + logger.debug("Syncing backup scheduler") + _sync_periodic_task() + except Exception as e: + # Log but don't fail startup if there's an issue + logger.warning(f"Failed to initialize backup scheduler: {e}") diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 7af6e939..cfc499da 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -21,6 +21,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") INSTALLED_APPS = [ "apps.api", "apps.accounts", + "apps.backups.apps.BackupsConfig", "apps.channels.apps.ChannelsConfig", "apps.dashboard", "apps.epg", From da5fbb1f5c470d2e24201246a71fe8cd19c9e88c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 4 Feb 2026 17:33:18 -0600 Subject: [PATCH 55/63] Change backup schedule initialization to support gunicorn as well. --- apps/backups/apps.py | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/backups/apps.py b/apps/backups/apps.py index d4bbb973..6b34d933 100644 --- a/apps/backups/apps.py +++ b/apps/backups/apps.py @@ -1,11 +1,24 @@ import logging import sys +import os from django.apps import AppConfig +import psutil logger = logging.getLogger(__name__) +def _is_worker_process(): + """Check if this process is a worker spawned by uwsgi/gunicorn.""" + try: + parent = psutil.Process(os.getppid()) + parent_name = parent.name() + return parent_name in ['uwsgi', 'gunicorn'] + except Exception: + # If we can't determine, assume it's not a worker (safe default) + return False + + class BackupsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.backups" @@ -13,9 +26,21 @@ class BackupsConfig(AppConfig): def ready(self): """Initialize backup scheduler on app startup.""" - # Only run in the main process (not in workers, beat, or management commands) - if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False: - self._sync_backup_scheduler() + # Skip management commands and celery + skip_commands = ['celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell', 'collectstatic', 'loaddata'] + if any(cmd in sys.argv for cmd in skip_commands): + return + + # Skip daphne dev server + if 'daphne' in sys.argv[0] if sys.argv else False: + return + + # Skip if this is a worker process spawned by uwsgi/gunicorn + if _is_worker_process(): + return + + # Proceed with syncing the backup scheduler + self._sync_backup_scheduler() def _sync_backup_scheduler(self): """Sync backup scheduler task to database.""" From de81dc6c80148160ec19f87949508bd399eea81a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 5 Feb 2026 13:48:01 -0600 Subject: [PATCH 56/63] Enhancement: Refactored app initialization to prevent redundant execution across multiple worker processes. Created `dispatcharr.app_initialization` utility module with `should_skip_initialization()` function that prevents custom initialization tasks (backup scheduler sync, developer notifications sync) from running during management commands, in worker processes, or in development servers. This significantly reduces startup overhead in multi-worker deployments (e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10 times). Applied to both `CoreConfig` and `BackupsConfig` apps. --- CHANGELOG.md | 1 + apps/backups/apps.py | 32 +++---------------- core/apps.py | 31 ++++++------------ dispatcharr/app_initialization.py | 52 +++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 49 deletions(-) create mode 100644 dispatcharr/app_initialization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f711d67c..babec5d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors. +- App initialization efficiency: Refactored app initialization to prevent redundant execution across multiple worker processes. Created `dispatcharr.app_initialization` utility module with `should_skip_initialization()` function that prevents custom initialization tasks (backup scheduler sync, developer notifications sync) from running during management commands, in worker processes, or in development servers. This significantly reduces startup overhead in multi-worker deployments (e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10 times). Applied to both `CoreConfig` and `BackupsConfig` apps. - M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. - Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues. - Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. diff --git a/apps/backups/apps.py b/apps/backups/apps.py index 6b34d933..d22ffb29 100644 --- a/apps/backups/apps.py +++ b/apps/backups/apps.py @@ -1,24 +1,10 @@ import logging -import sys -import os from django.apps import AppConfig -import psutil logger = logging.getLogger(__name__) -def _is_worker_process(): - """Check if this process is a worker spawned by uwsgi/gunicorn.""" - try: - parent = psutil.Process(os.getppid()) - parent_name = parent.name() - return parent_name in ['uwsgi', 'gunicorn'] - except Exception: - # If we can't determine, assume it's not a worker (safe default) - return False - - class BackupsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "apps.backups" @@ -26,25 +12,17 @@ class BackupsConfig(AppConfig): def ready(self): """Initialize backup scheduler on app startup.""" - # Skip management commands and celery - skip_commands = ['celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell', 'collectstatic', 'loaddata'] - if any(cmd in sys.argv for cmd in skip_commands): + from dispatcharr.app_initialization import should_skip_initialization + + # Skip if this is a management command, worker process, or dev server + if should_skip_initialization(): return - # Skip daphne dev server - if 'daphne' in sys.argv[0] if sys.argv else False: - return - - # Skip if this is a worker process spawned by uwsgi/gunicorn - if _is_worker_process(): - return - - # Proceed with syncing the backup scheduler + logger.debug("Syncing backup scheduler on app startup") self._sync_backup_scheduler() def _sync_backup_scheduler(self): """Sync backup scheduler task to database.""" - # Import here to avoid circular imports from core.models import CoreSettings from .scheduler import _sync_periodic_task, DEFAULTS try: diff --git a/core/apps.py b/core/apps.py index 12f6d054..f2780bd1 100644 --- a/core/apps.py +++ b/core/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig from django.conf import settings -import os, logging +import logging # Define TRACE level (5 is below DEBUG which is 10) TRACE = 5 @@ -15,6 +15,7 @@ def trace(self, message, *args, **kwargs): # Add the trace method to the Logger class logging.Logger.trace = trace + class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' @@ -22,13 +23,15 @@ class CoreConfig(AppConfig): def ready(self): # Import signals to ensure they get registered import core.signals + from dispatcharr.app_initialization import should_skip_initialization # Sync developer notifications and check for version updates on startup - # Only run in the main process (not in management commands or migrations) - import sys - if 'runserver' in sys.argv or 'uwsgi' in sys.argv[0] if sys.argv else False: - self._sync_developer_notifications() - self._check_version_update() + # Only run in the main process (not in management commands, migrations, or workers) + if should_skip_initialization(): + return + + self._sync_developer_notifications() + self._check_version_update() def _sync_developer_notifications(self): """Sync developer notifications from JSON file to database.""" @@ -37,22 +40,6 @@ class CoreConfig(AppConfig): logger = logging.getLogger(__name__) - # Check if tables exist (avoid running during migrations) - try: - with connection.cursor() as cursor: - cursor.execute( - "SELECT 1 FROM information_schema.tables WHERE table_name = 'core_systemnotification'" - ) - if not cursor.fetchone(): - # For SQLite - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='core_systemnotification'" - ) - if not cursor.fetchone(): - return - except Exception: - # If we can't check, the table might not exist yet - pass try: from core.developer_notifications import sync_developer_notifications diff --git a/dispatcharr/app_initialization.py b/dispatcharr/app_initialization.py new file mode 100644 index 00000000..e2fc12d6 --- /dev/null +++ b/dispatcharr/app_initialization.py @@ -0,0 +1,52 @@ +"""Utilities for managing app initialization across multiple processes.""" + +import sys +import os +import psutil +import logging + +logger = logging.getLogger(__name__) + + +def _is_worker_process(): + """Check if this process is a worker spawned by uwsgi/gunicorn.""" + try: + parent = psutil.Process(os.getppid()) + parent_name = parent.name() + return parent_name in ['uwsgi', 'gunicorn'] + except Exception: + # If we can't determine, assume it's not a worker (safe default) + return False + + +def should_skip_initialization(): + """ + Determine if app initialization should be skipped in this process. + + Returns True if: + - A management command is being run (migrate, celery, shell, etc.) + - The development server (daphne) is running + - This is a worker process (not the master) + + This prevents redundant initialization across multiple worker processes. + """ + # Skip management commands and background services + skip_commands = [ + 'celery', 'beat', 'migrate', 'makemigrations', 'shell', 'dbshell', + 'collectstatic', 'loaddata' + ] + if any(cmd in sys.argv for cmd in skip_commands): + logger.debug(f"Skipping initialization due to command: {sys.argv}") + return True + + # Skip daphne development server (single process, no need to guard) + if 'daphne' in sys.argv[0] if sys.argv else False: + logger.debug(f"Skipping initialization in daphne development server. Command: {sys.argv}") + return True + + # Skip if this is a worker process spawned by uwsgi/gunicorn + if _is_worker_process(): + logger.debug(f"Skipping initialization in worker process. Command: {sys.argv}") + return True + + return False From 21b7303e6009f8097748c1eaec47e3dde30afa81 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 5 Feb 2026 14:42:31 -0600 Subject: [PATCH 57/63] Bug fix: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. --- CHANGELOG.md | 1 + apps/output/views.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index babec5d1..9a79a9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. - Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention. - Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) diff --git a/apps/output/views.py b/apps/output/views.py index 58707ae2..1d53237a 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -2307,18 +2307,22 @@ def xc_get_epg(request, user, short=False): # Has stored programs, use them if short == False: programs = channel.epg_data.programs.filter( - start_time__gte=django_timezone.now() + end_time__gt=django_timezone.now() ).order_by('start_time') else: - programs = channel.epg_data.programs.all().order_by('start_time')[:limit] + programs = channel.epg_data.programs.filter( + end_time__gt=django_timezone.now() + ).order_by('start_time')[:limit] else: # Regular EPG with stored programs if short == False: programs = channel.epg_data.programs.filter( - start_time__gte=django_timezone.now() + end_time__gt=django_timezone.now() ).order_by('start_time') else: - programs = channel.epg_data.programs.all().order_by('start_time')[:limit] + programs = channel.epg_data.programs.filter( + end_time__gt=django_timezone.now() + ).order_by('start_time')[:limit] else: # No EPG data assigned, generate default dummy programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None) From 85d944894db136711781c58ce9217b77929da082 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 5 Feb 2026 14:44:40 -0600 Subject: [PATCH 58/63] changelog: Update changelog for PR 931 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a79a9cd..8b66d31e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). - EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. +- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir) ## [0.18.1] - 2026-01-27 From b8e1785d0e118f814e5a50c810f2237a5cb263d9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 5 Feb 2026 14:58:13 -0600 Subject: [PATCH 59/63] changelog: Update changelog to reference issue. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b66d31e..6ffafc56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. +- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. (Fixes #915) - Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention. - Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) From 78a53e03dbdd2976b9e0bd19079795d3bb3ae2d9 Mon Sep 17 00:00:00 2001 From: Dispatcharr Date: Thu, 5 Feb 2026 20:05:29 -0600 Subject: [PATCH 60/63] Plugin fixes/updates Added password fields - #616 Fixed multiple GUI issues - #494 Adds some QoL upgrades See CHANGELOG.md for more info --- CHANGELOG.md | 19 + Plugins.md | 250 +++++++- apps/plugins/api_urls.py | 2 + apps/plugins/api_views.py | 236 ++++++-- apps/plugins/loader.py | 573 ++++++++++++++++-- apps/plugins/serializers.py | 25 +- frontend/src/api.js | 1 + frontend/src/components/Field.jsx | 37 +- frontend/src/components/cards/PluginCard.jsx | 152 +++-- frontend/src/pages/Plugins.jsx | 13 +- frontend/src/pages/__tests__/Plugins.test.jsx | 3 + frontend/src/utils/pages/PluginsUtils.js | 5 +- .../pages/__tests__/PluginsUtils.test.js | 18 + 13 files changed, 1192 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffafc56..9de64782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `useWarningsStore` tests for warning suppression functionality - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) - EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name. +- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing. +- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown. +- Plugin action buttons can define `button_label`, `button_variant`, and `button_color` (e.g., Stop in red), falling back to “Run” for older plugins. +- Plugin card metadata: plugins can specify `author` and `help_url` in `plugin.json` to show author and docs link in the UI. +- Plugin cards can now be expanded/collapsed by clicking the header or chevron to hide settings and actions. ### Changed @@ -58,6 +64,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. - Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir) +- Hardened plugin loading to avoid executing plugin code unless the plugin is enabled. +- Prevented plugin package names from shadowing standard library or installed modules by namespacing plugin imports with safe aliases. +- Added safety limits to plugin ZIP imports (file count and size caps) and sanitized plugin keys derived from uploads. +- Enforced strict boolean parsing for plugin enable/disable requests to avoid accidental enables from truthy strings. +- Applied plugin field defaults server-side when running actions so plugins receive expected settings even before a user saves. +- Plugin settings UI improvements: render `info`/`text` fields, support `input_type: password`, show descriptions/placeholders, surface save failures, and keep settings in sync after refresh. +- Disabled plugins now collapse settings/actions to match the closed state before first enable. +- Plugin card header controls (delete/version/toggle) now stay right-aligned even with long descriptions. +- Improved plugin logo resolution (case-insensitive paths + absolute URLs), fixing dev UI logo loading without a Vite proxy. +- Plugin reload now hits the backend, clears module caches across workers, and refreshes the UI so code changes apply without a full backend restart. +- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths. +- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action. +- Plugin enable/disable toggles now update immediately without requiring a full page refresh. ## [0.18.1] - 2026-01-27 diff --git a/Plugins.md b/Plugins.md index 62ea0d87..d528f1e4 100644 --- a/Plugins.md +++ b/Plugins.md @@ -8,7 +8,43 @@ This document explains how to build, install, and use Python plugins in Dispatch 1) Create a folder under `/app/data/plugins/my_plugin/` (host path `data/plugins/my_plugin/` in the repo). -2) Add a `plugin.py` file exporting a `Plugin` class: +2) Add a `plugin.json` manifest (new standard) and a `plugin.py` file: + +`/app/data/plugins/my_plugin/plugin.json` +```json +{ + "name": "My Plugin", + "version": "0.1.0", + "description": "Does something useful", + "author": "Acme Labs", + "help_url": "https://example.com/docs/my-plugin", + "fields": [ + { "id": "enabled", "label": "Enabled", "type": "boolean", "default": true }, + { "id": "limit", "label": "Item limit", "type": "number", "default": 5 }, + { + "id": "mode", + "label": "Mode", + "type": "select", + "default": "safe", + "options": [ + { "value": "safe", "label": "Safe" }, + { "value": "fast", "label": "Fast" } + ] + }, + { "id": "note", "label": "Note", "type": "string", "default": "" } + ], + "actions": [ + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue" + } + ] +} +``` ``` # /app/data/plugins/my_plugin/plugin.py @@ -16,6 +52,8 @@ class Plugin: name = "My Plugin" version = "0.1.0" description = "Does something useful" + author = "Acme Labs" + help_url = "https://example.com/docs/my-plugin" # Settings fields rendered by the UI and persisted by the backend fields = [ @@ -31,7 +69,14 @@ class Plugin: # Actions appear as buttons. Clicking one calls run(action, params, context) actions = [ - {"id": "do_work", "label": "Do Work", "description": "Process items"}, + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue", + }, ] def run(self, action: str, params: dict, context: dict): @@ -59,8 +104,10 @@ class Plugin: - Each plugin is a directory containing either: - `plugin.py` exporting a `Plugin` class, or - a Python package (`__init__.py`) exporting a `Plugin` class. +- New standard: include a `plugin.json` manifest alongside your code for safe metadata discovery. +- Optional: include `logo.png` next to `plugin.py` to show a logo in the UI. -The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`). +The directory name (lowercased, spaces as `_`) is used as the registry key. Plugins are imported under a safe internal package name; if the folder name is a valid identifier (and not reserved), it is also registered as an alias for convenience. --- @@ -69,7 +116,8 @@ The directory name (lowercased, spaces as `_`) is used as the registry key and m - Discovery runs at server startup and on-demand when: - Fetching the plugins list from the UI - Hitting `POST /api/plugins/plugins/reload/` -- The loader imports each plugin module and instantiates `Plugin()`. +- The loader reads `plugin.json` for metadata without executing plugin code. +- Plugin code is only imported and instantiated when the plugin is enabled. - Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB. Backend code: @@ -80,6 +128,45 @@ Backend code: --- +## Plugin Manifest (`plugin.json`) + +`plugin.json` lets Dispatcharr list your plugin safely without executing code. It should live next to `plugin.py`. + +Example: +``` +{ + "name": "My Plugin", + "version": "1.2.3", + "description": "Does something useful", + "author": "Acme Labs", + "help_url": "https://example.com/docs/my-plugin", + "fields": [ + { "id": "limit", "label": "Item limit", "type": "number", "default": 5 } + ], + "actions": [ + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue" + } + ] +} +``` + +Notes: +- `author` and `help_url` are optional. If provided, the UI shows “By {author}” and a Docs link. +- If your plugin includes a `logo.png` file next to `plugin.py`, it will be shown on the plugin card. + +If `plugin.json` is missing or invalid, the plugin is treated as **legacy**: +- The name is inferred from the folder name. +- `logo.png` still displays if present. +- The UI shows a warning asking the developer to upgrade to the new standard. + +--- + ## Plugin Interface Export a `Plugin` class. Supported attributes and behavior: @@ -87,34 +174,72 @@ Export a `Plugin` class. Supported attributes and behavior: - `name` (str): Human-readable name. - `version` (str): Semantic version string. - `description` (str): Short description. +- `author` (str, optional): Author or team name shown on the card. +- `help_url` (str, optional): Docs/support link shown on the card. - `fields` (list): Settings schema used by the UI to render controls. -- `actions` (list): Available actions; the UI renders a Run button for each. +- `actions` (list): Available actions; the UI renders a button for each (defaults to Run). - `run(action, params, context)` (callable): Invoked when a user clicks an action. +- `stop(context)` (optional callable): Invoked when the plugin is disabled, deleted, or reloaded so you can gracefully shut down any processes you started. If `stop()` is not defined but you have an action with id `stop`, Dispatcharr will call `run("stop", {}, context)` as a fallback. ### Settings Schema Supported field `type`s: - `boolean` - `number` -- `string` +- `string` (single-line text) +- `text` (multi-line textarea) - `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`) +- `info` (display-only text; useful for headings or notes) Common field keys: - `id` (str): Settings key. - `label` (str): Label shown in the UI. - `type` (str): One of above. - `default` (any): Default value used until saved. -- `help_text` (str, optional): Shown under the control. +- `help_text` / `description` (str, optional): Shown under the control. +- `placeholder` (str, optional): Placeholder text for inputs. +- `input_type` (str, optional): For `string` fields, set to `"password"` to mask input. - `options` (list, for select): List of `{value, label}`. +Notes: +- For `info` fields, you can use `description`/`help_text` (or `value`) to show the text. + The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`. +### Example: stop() Hook +``` +import signal + +class Plugin: + name = "Example Plugin" + version = "1.0.0" + description = "Shows how to shut down gracefully." + + def run(self, action: str, params: dict, context: dict): + # Start a subprocess or background task here and store its PID. + # Example: save pid in /data or in your own module-level variable. + return {"status": "ok"} + + def stop(self, context: dict): + logger = context.get("logger") + pid = self._read_pid() # your helper + if pid: + try: + os.kill(pid, signal.SIGTERM) + logger.info("Stopped process %s", pid) + except Exception: + logger.exception("Failed to stop process %s", pid) +``` + Read settings in `run` via `context["settings"]`. ### Actions Each action is a dict: - `id` (str): Unique action id. -- `label` (str): Button label. +- `label` (str): Action label. - `description` (str, optional): Helper text. +- `button_label` (str, optional): Button text (defaults to “Run”). +- `button_variant` (str, optional): Button style (Mantine variants like `filled`, `outline`, `subtle`). +- `button_color` (str, optional): Button color (e.g., `red`, `blue`, `orange`). Clicking an action calls your plugin’s `run(action, params, context)` and shows a notification with the result or error. @@ -182,6 +307,110 @@ Plugins are server-side Python code running within the Django application. You c Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking. +### Important: Don’t Ask Users for URL/User/Password +Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities. +Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because: + +- It encourages users to enter privileged credentials. +- Malicious plugins could exfiltrate credentials. +- It duplicates access that plugins already have internally. + +If you are writing a plugin, **use internal Python APIs** (models/tasks/utils) instead of making HTTP calls with user credentials. + +### When You Do Need HTTP +In rare cases you may need to call a Dispatcharr HTTP endpoint (for example, to reuse an existing API response serializer). In that case: + +1. **Do not ask the user for credentials.** + Use the backend’s internal access where possible. + +2. Prefer **local/internal URLs** (never user-provided): + - Docker: `http://web:9191` (service name inside the container network) + - Dev: `http://127.0.0.1:5656` + +3. Use Django helpers when building URLs: + ``` + from django.urls import reverse + path = reverse("api:channels:list") # example name + url = f"http://127.0.0.1:5656{path}" + ``` + +4. Use a short timeout and robust error handling: + ``` + import requests + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = resp.json() + ``` + +### Examples: Preferred Internal Access (No HTTP, No Credentials) + +**Example 1: List channels directly from the DB** +``` +from apps.channels.models import Channel + +channels = Channel.objects.all().values("id", "name", "number")[:50] +return {"status": "ok", "channels": list(channels)} +``` + +**Example 2: Kick off an existing refresh task** +``` +from apps.m3u.tasks import refresh_m3u_accounts +from apps.epg.tasks import refresh_all_epg_data + +refresh_m3u_accounts.delay() +refresh_all_epg_data.delay() +return {"status": "queued"} +``` + +**Example 3: Send a WebSocket update to the UI** +``` +from core.utils import send_websocket_update + +send_websocket_update( + "updates", + "update", + {"type": "plugin", "plugin": "my_plugin", "message": "Refresh queued"} +) +``` + +### Example: HTTP Access (Only If You Must) + +**Find the endpoint** +- Use `reverse()` with the named route when possible. +- If you don’t know the route name, inspect `apps/*/api_urls.py` or Django’s URL config to find it. + +``` +from django.urls import reverse +import requests + +path = reverse("api:channels:list") # named route from apps/channels/api_urls.py +url = f"http://127.0.0.1:5656{path}" + +resp = requests.get(url, timeout=10) +resp.raise_for_status() +data = resp.json() +``` + +### How Developers Find the API + +1. **Prefer internal models/tasks** (best and safest). +2. **Check `apps/*/api_urls.py`** for named routes and endpoint patterns. + - Example: `apps/channels/api_urls.py` for channel endpoints. +3. **Find the view** referenced in the URL config to see required params. + - Example: `apps/channels/api_views.py` or `apps/epg/api_views.py`. +4. **Use `reverse()`** with the named route to build the path. + - This avoids hardcoding paths and keeps plugins compatible if URLs change. +5. **Only use internal hostnames** (never user-provided URL). + +### What Plugins Can Access +Because plugins run inside the server process, they can: +- Read and write database models (same permissions as the app) +- Invoke Celery tasks +- Send websocket updates +- Read configuration and settings + +Treat plugins as **trusted server code** and avoid exposing sensitive data in plugin settings or logs. + --- ## REST Endpoints (for UI and tooling) @@ -203,7 +432,9 @@ Notes: - In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder. - The archive should contain either `plugin.py` or a Python package (`__init__.py`). +- Include `plugin.json` in the plugin folder to provide metadata without executing code. - On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default). + - If `plugin.json` is missing, the plugin is marked as legacy and the UI will show a warning. --- @@ -214,6 +445,7 @@ Notes: - The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code. - The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button. - Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403. +- Dispatcharr will not import or execute plugin code unless the plugin is enabled. --- @@ -263,7 +495,7 @@ class Plugin: ## Troubleshooting - Plugin not listed: ensure the folder exists and contains `plugin.py` with a `Plugin` class. -- Import errors: the folder name is the import name; avoid spaces or exotic characters. +- Import errors: ensure the folder contains `plugin.py` or a package `__init__.py`. Folder names with spaces or dashes are supported; if you need to import by folder name inside your plugin, use a valid Python identifier. - No confirmation: include a boolean field with `id: "confirm"` and set it to true or default true. - HTTP 403 on run: the plugin is disabled; enable it from the toggle or via the `enabled/` endpoint. diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py index a229a07c..5ba85be2 100644 --- a/apps/plugins/api_urls.py +++ b/apps/plugins/api_urls.py @@ -7,6 +7,7 @@ from .api_views import ( PluginEnabledAPIView, PluginImportAPIView, PluginDeleteAPIView, + PluginLogoAPIView, ) app_name = "plugins" @@ -19,4 +20,5 @@ urlpatterns = [ path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), path("plugins//run/", PluginRunAPIView.as_view(), name="run"), path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), + path("plugins//logo/", PluginLogoAPIView.as_view(), name="logo"), ] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py index 0d68fc7d..624dcc4d 100644 --- a/apps/plugins/api_views.py +++ b/apps/plugins/api_views.py @@ -1,19 +1,21 @@ import logging +import re from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.decorators import api_view from django.conf import settings from django.core.files.uploadedfile import UploadedFile -import io +from django.http import FileResponse import os import zipfile import shutil import tempfile +from urllib.parse import urlparse from apps.accounts.permissions import ( Authenticated, permission_classes_by_method, ) +from dispatcharr.utils import network_access_allowed from .loader import PluginManager from .models import PluginConfig @@ -21,6 +23,42 @@ from .models import PluginConfig logger = logging.getLogger(__name__) +MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000) +MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024) +MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024) + + +def _parse_bool(value): + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "y", "on"): + return True + if normalized in ("false", "0", "no", "n", "off"): + return False + return None + + +def _sanitize_plugin_key(value: str) -> str: + base = os.path.basename(value or "") + base = base.replace(" ", "_").lower() + base = re.sub(r"[^a-z0-9_-]", "_", base) + base = base.strip("._-") + return base or "plugin" + + +def _absolutize_logo_url(request, url: str | None) -> str | None: + if not url or not request: + return url + parsed = urlparse(url) + if parsed.scheme: + return url + return request.build_absolute_uri(url) + + class PluginsListAPIView(APIView): def get_permissions(self): try: @@ -32,9 +70,12 @@ class PluginsListAPIView(APIView): def get(self, request): pm = PluginManager.get() - # Ensure registry is up-to-date on each request - pm.discover_plugins() - return Response({"plugins": pm.list_plugins()}) + # Prefer cached registry; reload explicitly via the reload endpoint + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + for plugin in plugins: + plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url")) + return Response({"plugins": plugins}) class PluginReloadAPIView(APIView): @@ -48,7 +89,8 @@ class PluginReloadAPIView(APIView): def post(self, request): pm = PluginManager.get() - pm.discover_plugins() + pm.stop_all_plugins(reason="reload") + pm.discover_plugins(force_reload=True) return Response({"success": True, "count": len(pm._registry)}) @@ -81,6 +123,19 @@ class PluginImportAPIView(APIView): if not file_members: shutil.rmtree(tmp_root, ignore_errors=True) return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST) + if len(file_members) > MAX_PLUGIN_IMPORT_FILES: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive has too many files"}, status=status.HTTP_400_BAD_REQUEST) + + total_size = 0 + for member in file_members: + total_size += member.file_size + if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive contains a file that is too large"}, status=status.HTTP_400_BAD_REQUEST) + if total_size > MAX_PLUGIN_IMPORT_BYTES: + shutil.rmtree(tmp_root, ignore_errors=True) + return Response({"success": False, "error": "Archive is too large"}, status=status.HTTP_400_BAD_REQUEST) for member in file_members: name = member.filename @@ -115,7 +170,31 @@ class PluginImportAPIView(APIView): plugin_key = os.path.basename(chosen.rstrip(os.sep)) if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): plugin_key = base_name - plugin_key = plugin_key.replace(" ", "_").lower() + plugin_key = _sanitize_plugin_key(plugin_key) + if len(plugin_key) > 128: + plugin_key = plugin_key[:128] + logo_bytes = None + try: + logo_candidates = [] + chosen_abs = os.path.abspath(chosen) + for dirpath, _, filenames in os.walk(tmp_root): + for filename in filenames: + if filename.lower() != "logo.png": + continue + full_path = os.path.join(dirpath, filename) + full_abs = os.path.abspath(full_path) + try: + in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs + except Exception: + in_chosen = False + depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) + logo_candidates.append((0 if in_chosen else 1, depth, full_path)) + if logo_candidates: + logo_candidates.sort() + with open(logo_candidates[0][2], "rb") as fh: + logo_bytes = fh.read() + except Exception: + logo_bytes = None final_dir = os.path.join(plugins_dir, plugin_key) if os.path.exists(final_dir): @@ -136,6 +215,12 @@ class PluginImportAPIView(APIView): shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item)) else: shutil.move(chosen, final_dir) + if logo_bytes: + try: + with open(os.path.join(final_dir, "logo.png"), "wb") as fh: + fh.write(logo_bytes) + except Exception: + pass # Cleanup temp shutil.rmtree(tmp_root, ignore_errors=True) target_dir = final_dir @@ -145,49 +230,50 @@ class PluginImportAPIView(APIView): except Exception: pass - # Reload discovery and validate plugin entry - pm.discover_plugins() - plugin = pm._registry.get(plugin_key) - if not plugin: - # Cleanup the copied folder to avoid leaving invalid plugin behind - try: - shutil.rmtree(target_dir, ignore_errors=True) - except Exception: - pass - return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST) - - # Extra validation: ensure Plugin.run exists - instance = getattr(plugin, "instance", None) - run_method = getattr(instance, "run", None) - if not callable(run_method): - try: - shutil.rmtree(target_dir, ignore_errors=True) - except Exception: - pass - return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST) - - # Find DB config to return enabled/ever_enabled + # Ensure DB config exists (untrusted plugins are registered without loading) try: - cfg = PluginConfig.objects.get(key=plugin_key) - enabled = cfg.enabled - ever_enabled = getattr(cfg, "ever_enabled", False) - except PluginConfig.DoesNotExist: - enabled = False - ever_enabled = False + cfg, _ = PluginConfig.objects.get_or_create( + key=plugin_key, + defaults={ + "name": plugin_key, + "version": "", + "description": "", + "settings": {}, + }, + ) + except Exception: + cfg = None - return Response({ - "success": True, - "plugin": { - "key": plugin.key, - "name": plugin.name, - "version": plugin.version, - "description": plugin.description, - "enabled": enabled, - "ever_enabled": ever_enabled, - "fields": plugin.fields or [], - "actions": plugin.actions or [], + # Reload discovery to register the plugin (trusted plugins will load) + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None) + except Exception: + plugin_entry = None + + if not plugin_entry: + logo_path = os.path.join(plugins_dir, plugin_key, "logo.png") + logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None + legacy = not os.path.isfile(os.path.join(plugins_dir, plugin_key, "plugin.json")) + plugin_entry = { + "key": plugin_key, + "name": cfg.name if cfg else plugin_key, + "version": cfg.version if cfg else "", + "description": cfg.description if cfg else "", + "enabled": cfg.enabled if cfg else False, + "ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False, + "fields": [], + "actions": [], + "trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)), + "loaded": False, + "missing": False, + "legacy": legacy, + "logo_url": logo_url, } - }) + + plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) + return Response({"success": True, "plugin": plugin_entry}) class PluginSettingsAPIView(APIView): @@ -254,21 +340,63 @@ class PluginEnabledAPIView(APIView): return [Authenticated()] def post(self, request, key): - enabled = request.data.get("enabled") - if enabled is None: + enabled_raw = request.data.get("enabled") + if enabled_raw is None: return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST) + enabled = _parse_bool(enabled_raw) + if enabled is None: + return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST) try: cfg = PluginConfig.objects.get(key=key) - cfg.enabled = bool(enabled) + pm = PluginManager.get() + if not enabled and cfg.enabled: + try: + pm.stop_plugin(key, reason="disable") + except Exception: + logger.exception("Failed to stop plugin '%s' on disable", key) + cfg.enabled = enabled # Mark that this plugin has been enabled at least once if cfg.enabled and not cfg.ever_enabled: cfg.ever_enabled = True cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"]) - return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled}) + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None) + except Exception: + plugin_entry = None + response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled} + if plugin_entry: + plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) + response["plugin"] = plugin_entry + return Response(response) except PluginConfig.DoesNotExist: return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND) +class PluginLogoAPIView(APIView): + def get_permissions(self): + return [] + + def get(self, request, key): + if not network_access_allowed(request, "UI"): + return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN) + pm = PluginManager.get() + pm.discover_plugins(use_cache=True) + plugins_dir = pm.plugins_dir + logo_path = os.path.join(plugins_dir, key, "logo.png") + lp = pm.get_plugin(key) + if lp and getattr(lp, "path", None): + logo_path = os.path.join(lp.path, "logo.png") + abs_plugins = os.path.abspath(plugins_dir) + os.sep + abs_target = os.path.abspath(logo_path) + if not abs_target.startswith(abs_plugins): + return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST) + if not os.path.isfile(logo_path): + return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND) + return FileResponse(open(logo_path, "rb"), content_type="image/png") + + class PluginDeleteAPIView(APIView): def get_permissions(self): try: @@ -280,6 +408,10 @@ class PluginDeleteAPIView(APIView): def delete(self, request, key): pm = PluginManager.get() + try: + pm.stop_plugin(key, reason="delete") + except Exception: + logger.exception("Failed to stop plugin '%s' before delete", key) plugins_dir = pm.plugins_dir target_dir = os.path.join(plugins_dir, key) # Safety: ensure path inside plugins_dir @@ -302,5 +434,5 @@ class PluginDeleteAPIView(APIView): pass # Reload registry - pm.discover_plugins() + pm.discover_plugins(force_reload=True) return Response({"success": True}) diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py index 5422ae7e..4b53e08b 100644 --- a/apps/plugins/loader.py +++ b/apps/plugins/loader.py @@ -1,8 +1,12 @@ import importlib +import importlib.util import json import logging import os +import re import sys +import threading +import types from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -19,10 +23,17 @@ class LoadedPlugin: name: str version: str = "" description: str = "" + author: str = "" + help_url: str = "" module: Any = None instance: Any = None fields: List[Dict[str, Any]] = field(default_factory=list) actions: List[Dict[str, Any]] = field(default_factory=list) + trusted: bool = False + loaded: bool = False + path: Optional[str] = None + folder_name: Optional[str] = None + legacy: bool = False class PluginManager: @@ -39,70 +50,254 @@ class PluginManager: def __init__(self) -> None: self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins") self._registry: Dict[str, LoadedPlugin] = {} + self._package_names: Dict[str, str] = {} + self._alias_names: Dict[str, str] = {} + self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token") + self._last_reload_token = 0.0 + self._lock = threading.RLock() # Ensure plugins directory exists os.makedirs(self.plugins_dir, exist_ok=True) if self.plugins_dir not in sys.path: sys.path.append(self.plugins_dir) - def discover_plugins(self, *, sync_db: bool = True) -> Dict[str, LoadedPlugin]: + def discover_plugins( + self, + *, + sync_db: bool = True, + force_reload: bool = False, + use_cache: bool = False, + ) -> Dict[str, LoadedPlugin]: + token = self._get_reload_token() + if use_cache and not force_reload: + with self._lock: + if self._registry and token <= self._last_reload_token: + return self._registry + if token > self._last_reload_token: + force_reload = True + if force_reload: + self._touch_reload_token() + token = self._get_reload_token() + if sync_db: logger.info(f"Discovering plugins in {self.plugins_dir}") else: logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}") - self._registry.clear() + + with self._lock: + previous_packages = dict(self._package_names) + previous_aliases = dict(self._alias_names) + previous_paths = { + key: lp.path for key, lp in self._registry.items() if lp and lp.path + } try: + configs: Optional[Dict[str, PluginConfig]] = None + try: + configs = {c.key: c for c in PluginConfig.objects.all()} + except Exception: + # DB might not be ready; treat all plugins as untrusted + configs = None + + new_registry: Dict[str, LoadedPlugin] = {} + new_packages: Dict[str, str] = {} + new_aliases: Dict[str, str] = {} for entry in sorted(os.listdir(self.plugins_dir)): path = os.path.join(self.plugins_dir, entry) if not os.path.isdir(path): continue + has_pkg = os.path.exists(os.path.join(path, "__init__.py")) + has_pluginpy = os.path.exists(os.path.join(path, "plugin.py")) + if not (has_pkg or has_pluginpy): + continue + plugin_key = entry.replace(" ", "_").lower() + alias_name = self._resolve_alias_name(entry, path) + + if force_reload: + prev_alias = previous_aliases.get(plugin_key) + if prev_alias: + self._unload_alias(prev_alias) + prev_path = previous_paths.get(plugin_key) + if prev_path: + self._unload_path_modules(prev_path) + + cfg = configs.get(plugin_key) if configs else None + enabled = bool(cfg and cfg.enabled) + trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled)) + + manifest, has_manifest = self._read_manifest(path) + legacy = not has_manifest + manifest_name = None + manifest_version = None + manifest_description = None + manifest_author = None + manifest_help_url = None + manifest_fields: List[Dict[str, Any]] = [] + manifest_actions: List[Dict[str, Any]] = [] + if has_manifest and isinstance(manifest, dict): + manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None + manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None + manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None + manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None + manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None + manifest_fields = self._normalize_fields(manifest.get("fields", [])) + manifest_actions = self._normalize_actions(manifest.get("actions", [])) + + display_name = manifest_name or entry + display_version = ( + manifest_version if manifest_version is not None else (cfg.version if cfg else "") + ) + display_description = ( + manifest_description if manifest_description is not None else (cfg.description if cfg else "") + ) + + def _make_placeholder() -> LoadedPlugin: + return LoadedPlugin( + key=plugin_key, + name=display_name, + version=display_version, + description=display_description, + author=manifest_author or "", + help_url=manifest_help_url or "", + fields=manifest_fields if has_manifest else [], + actions=manifest_actions if has_manifest else [], + trusted=trusted, + loaded=False, + path=path, + folder_name=entry, + legacy=legacy, + ) + + if not enabled: + new_registry[plugin_key] = _make_placeholder() + continue try: - self._load_plugin(plugin_key, path) + lp, package_name = self._load_plugin( + plugin_key, + path, + folder_name=entry, + force_reload=force_reload, + previous_package=previous_packages.get(plugin_key), + ) + if lp: + if manifest_name and (not lp.name or lp.name == plugin_key): + lp.name = manifest_name + if manifest_version is not None and not lp.version: + lp.version = manifest_version + if manifest_description is not None and not lp.description: + lp.description = manifest_description + if manifest_author is not None and not lp.author: + lp.author = manifest_author + if manifest_help_url is not None and not lp.help_url: + lp.help_url = manifest_help_url + if manifest_fields and not lp.fields: + lp.fields = manifest_fields + if manifest_actions and not lp.actions: + lp.actions = manifest_actions + lp.trusted = trusted + lp.loaded = True + lp.path = path + lp.folder_name = entry + lp.legacy = legacy + new_registry[plugin_key] = lp + if package_name: + new_packages[plugin_key] = package_name + if alias_name: + new_aliases[plugin_key] = alias_name + else: + new_registry[plugin_key] = _make_placeholder() except Exception: logger.exception(f"Failed to load plugin '{plugin_key}' from {path}") + new_registry[plugin_key] = _make_placeholder() - logger.info(f"Discovered {len(self._registry)} plugin(s)") + if force_reload: + # Remove stale modules for plugins that no longer exist + removed_keys = set(previous_packages.keys()) - set(new_packages.keys()) + for key in removed_keys: + self._unload_package(previous_packages[key]) + prev_alias = previous_aliases.get(key) + if prev_alias: + self._unload_alias(prev_alias) + prev_path = previous_paths.get(key) + if prev_path: + self._unload_path_modules(prev_path) + + with self._lock: + self._registry = new_registry + self._package_names = new_packages + self._alias_names = new_aliases + if token > self._last_reload_token: + self._last_reload_token = token + + logger.info(f"Discovered {len(new_registry)} plugin(s)") except FileNotFoundError: logger.warning(f"Plugins directory not found: {self.plugins_dir}") # Sync DB records (optional) if sync_db: try: - self._sync_db_with_registry() + self._sync_db_with_registry(new_registry if 'new_registry' in locals() else None) except Exception: # Defer sync if database is not ready (e.g., first startup before migrate) logger.exception("Deferring plugin DB sync; database not ready yet") return self._registry - def _load_plugin(self, key: str, path: str): + def _load_plugin( + self, + key: str, + path: str, + *, + folder_name: str, + force_reload: bool, + previous_package: Optional[str], + ) -> tuple[Optional[LoadedPlugin], Optional[str]]: # Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present. has_pkg = os.path.exists(os.path.join(path, "__init__.py")) has_pluginpy = os.path.exists(os.path.join(path, "plugin.py")) if not (has_pkg or has_pluginpy): logger.debug(f"Skipping {path}: no plugin.py or package") - return + return None, None - candidate_modules = [] - if has_pluginpy: - candidate_modules.append(f"{key}.plugin") - if has_pkg: - candidate_modules.append(key) + package_name = self._resolve_package_name(key) + alias_name = self._resolve_alias_name(folder_name, path) + + if force_reload and previous_package: + self._unload_package(previous_package) module = None plugin_cls = None last_error = None - for module_name in candidate_modules: + + # Ensure a package context exists for plugin.py (even without __init__.py) + if has_pluginpy: + self._ensure_namespace_package(package_name, path, alias=alias_name) + + module_name = f"{package_name}.plugin" + plugin_path = os.path.join(path, "plugin.py") try: - logger.debug(f"Importing plugin module {module_name}") - module = importlib.import_module(module_name) + logger.debug(f"Importing plugin module {module_name} from {plugin_path}") + module = self._load_module_from_path(module_name, plugin_path, is_package=False) + if alias_name: + self._register_alias_module(f"{alias_name}.plugin", module, path) plugin_cls = getattr(module, "Plugin", None) - if plugin_cls is not None: - break - else: + if plugin_cls is None: + logger.warning(f"Module {module_name} has no Plugin class") + except Exception as e: + last_error = e + logger.exception(f"Error importing module {module_name}") + + if plugin_cls is None and has_pkg: + module_name = package_name + init_path = os.path.join(path, "__init__.py") + try: + logger.debug(f"Importing plugin package {module_name} from {init_path}") + module = self._load_module_from_path(module_name, init_path, is_package=True) + self._register_alias_module(alias_name, module, path) + plugin_cls = getattr(module, "Plugin", None) + if plugin_cls is None: logger.warning(f"Module {module_name} has no Plugin class") except Exception as e: last_error = e @@ -111,32 +306,43 @@ class PluginManager: if plugin_cls is None: if last_error: raise last_error - else: - logger.warning(f"No Plugin class found for {key}; skipping") - return + logger.warning(f"No Plugin class found for {key}; skipping") + return None, package_name instance = plugin_cls() name = getattr(instance, "name", key) version = getattr(instance, "version", "") description = getattr(instance, "description", "") + author = getattr(instance, "author", "") + help_url = getattr(instance, "help_url", "") fields = getattr(instance, "fields", []) actions = getattr(instance, "actions", []) + fields = self._normalize_fields(fields) + actions = self._normalize_actions(actions) - self._registry[key] = LoadedPlugin( + lp = LoadedPlugin( key=key, name=name, version=version, description=description, + author=author or "", + help_url=help_url or "", module=module, instance=instance, fields=fields, actions=actions, + path=path, + folder_name=folder_name, ) + return lp, package_name - def _sync_db_with_registry(self): + def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None): + if registry is None: + with self._lock: + registry = dict(self._registry) with transaction.atomic(): - for key, lp in self._registry.items(): + for key, lp in registry.items(): obj, _ = PluginConfig.objects.get_or_create( key=key, defaults={ @@ -164,6 +370,8 @@ class PluginManager: from .models import PluginConfig plugins: List[Dict[str, Any]] = [] + with self._lock: + registry_snapshot = dict(self._registry) try: configs = {c.key: c for c in PluginConfig.objects.all()} except Exception as e: @@ -172,25 +380,33 @@ class PluginManager: configs = {} # First, include all discovered plugins - for key, lp in self._registry.items(): + for key, lp in registry_snapshot.items(): conf = configs.get(key) + trusted = bool(conf and (conf.ever_enabled or conf.enabled)) + logo_url = self._get_logo_url(key, path=lp.path) plugins.append( { "key": key, "name": lp.name, "version": lp.version, "description": lp.description, + "author": getattr(lp, "author", "") or "", + "help_url": getattr(lp, "help_url", "") or "", "enabled": conf.enabled if conf else False, "ever_enabled": getattr(conf, "ever_enabled", False) if conf else False, "fields": lp.fields or [], "settings": (conf.settings if conf else {}), "actions": lp.actions or [], "missing": False, + "trusted": trusted, + "loaded": bool(lp.loaded), + "legacy": bool(getattr(lp, "legacy", False)), + "logo_url": logo_url, } ) # Then, include any DB-only configs (files missing or failed to load) - discovered_keys = set(self._registry.keys()) + discovered_keys = set(registry_snapshot.keys()) for key, conf in configs.items(): if key in discovered_keys: continue @@ -200,19 +416,26 @@ class PluginManager: "name": conf.name, "version": conf.version, "description": conf.description, + "author": "", + "help_url": "", "enabled": conf.enabled, "ever_enabled": getattr(conf, "ever_enabled", False), "fields": [], "settings": conf.settings or {}, "actions": [], "missing": True, + "trusted": bool(conf.ever_enabled or conf.enabled), + "loaded": False, + "legacy": False, + "logo_url": self._get_logo_url(key), } ) return plugins def get_plugin(self, key: str) -> Optional[LoadedPlugin]: - return self._registry.get(key) + with self._lock: + return self._registry.get(key) def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]: cfg = PluginConfig.objects.get(key=key) @@ -223,19 +446,18 @@ class PluginManager: def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: lp = self.get_plugin(key) if not lp or not lp.instance: - raise ValueError(f"Plugin '{key}' not found") + # Attempt a lightweight re-discovery in case the registry was rebuilt + self.discover_plugins(sync_db=False, force_reload=False, use_cache=False) + lp = self.get_plugin(key) + if not lp or not lp.instance: + raise ValueError(f"Plugin '{key}' not found") cfg = PluginConfig.objects.get(key=key) if not cfg.enabled: raise PermissionError(f"Plugin '{key}' is disabled") params = params or {} - # Provide a context object to the plugin - context = { - "settings": cfg.settings or {}, - "logger": logger, - "actions": {a.get("id"): a for a in (lp.actions or [])}, - } + context = self._build_context(lp, cfg) # Run either via Celery if plugin provides a delayed method, or inline run_method = getattr(lp.instance, "run", None) @@ -252,3 +474,286 @@ class PluginManager: if isinstance(result, dict): return result return {"status": "ok", "result": result} + + def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool: + lp = self.get_plugin(key) + if not lp or not lp.instance: + return False + try: + cfg = PluginConfig.objects.get(key=key) + except PluginConfig.DoesNotExist: + return False + if not cfg.enabled: + return False + + context = self._build_context(lp, cfg) + if reason: + context["reason"] = reason + + stop_method = getattr(lp.instance, "stop", None) + if callable(stop_method): + try: + stop_method(context) + return True + except TypeError: + try: + stop_method() + return True + except Exception: + logger.exception("Plugin '%s' stop() failed", key) + return False + except Exception: + logger.exception("Plugin '%s' stop() failed", key) + return False + + run_method = getattr(lp.instance, "run", None) + if callable(run_method): + actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)} + if "stop" in actions: + try: + run_method("stop", {}, context) + return True + except Exception: + logger.exception("Plugin '%s' stop action failed", key) + return False + return False + + def stop_all_plugins(self, reason: Optional[str] = None) -> int: + stopped = 0 + with self._lock: + registry_snapshot = dict(self._registry) + for key in registry_snapshot.keys(): + if self.stop_plugin(key, reason=reason): + stopped += 1 + return stopped + + def _resolve_package_name(self, key: str) -> str: + safe_key = self._safe_module_name(key) + return f"_dispatcharr_plugin_{safe_key}" + + def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]: + if not self._is_valid_identifier(folder_name): + return None + if self._is_reserved_module_name(folder_name, path): + return None + return folder_name + + def _is_valid_identifier(self, name: str) -> bool: + return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None + + def _safe_module_name(self, value: str) -> str: + safe = re.sub(r"[^0-9A-Za-z_]", "_", value) + if not safe or safe[0].isdigit(): + safe = f"p_{safe}" + return safe + + def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]: + try: + from .serializers import PluginFieldSerializer + except Exception: + return fields if isinstance(fields, list) else [] + if not isinstance(fields, list): + return [] + serializer = PluginFieldSerializer(data=fields, many=True) + if serializer.is_valid(): + return serializer.validated_data + normalized: List[Dict[str, Any]] = [] + for item in fields: + item_ser = PluginFieldSerializer(data=item) + if item_ser.is_valid(): + normalized.append(item_ser.validated_data) + else: + logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors) + return normalized + + def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]: + try: + from .serializers import PluginActionSerializer + except Exception: + return actions if isinstance(actions, list) else [] + if not isinstance(actions, list): + return [] + serializer = PluginActionSerializer(data=actions, many=True) + if serializer.is_valid(): + return serializer.validated_data + normalized: List[Dict[str, Any]] = [] + for item in actions: + item_ser = PluginActionSerializer(data=item) + if item_ser.is_valid(): + normalized.append(item_ser.validated_data) + else: + logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors) + return normalized + + def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]: + merged = dict(settings or {}) + for field_def in fields or []: + field_id = field_def.get("id") + if not field_id: + continue + if field_id not in merged and "default" in field_def: + merged[field_id] = field_def.get("default") + return merged + + def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]: + settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or []) + return { + "settings": settings, + "logger": logger, + "actions": {a.get("id"): a for a in (lp.actions or [])}, + } + + def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]: + manifest_path = os.path.join(path, "plugin.json") + if not os.path.isfile(manifest_path): + return None, False + try: + with open(manifest_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception: + logger.warning("Invalid plugin.json for plugin at %s", path) + return None, False + if not isinstance(data, dict): + logger.warning("plugin.json must be an object for plugin at %s", path) + return None, False + return data, True + + def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]: + logo_path = os.path.join(self.plugins_dir, key, "logo.png") + if path: + logo_path = os.path.join(path, "logo.png") + try: + if os.path.isfile(logo_path): + return f"/api/plugins/plugins/{key}/logo/" + except Exception: + return None + return None + + def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None: + existing = sys.modules.get(package_name) + if existing and getattr(existing, "__path__", None): + return + pkg = types.ModuleType(package_name) + pkg.__path__ = [path] + pkg.__package__ = package_name + sys.modules[package_name] = pkg + self._register_alias_module(alias, pkg, path) + + def _register_alias_module( + self, + alias_name: Optional[str], + module: Any, + path: str, + *, + force: bool = False, + ) -> None: + if not alias_name: + return + if self._is_reserved_module_name(alias_name, path): + return + if alias_name in sys.modules: + if not force: + return + self._unload_alias(alias_name) + sys.modules[alias_name] = module + + def _is_reserved_module_name(self, name: str, path: str) -> bool: + if name in sys.builtin_module_names: + return True + if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names: + return True + existing = sys.modules.get(name) + if existing: + origin = getattr(existing, "__file__", None) + if origin is None: + return True + try: + if not os.path.abspath(origin).startswith(os.path.abspath(path)): + return True + except Exception: + return True + try: + spec = importlib.util.find_spec(name) + except Exception: + spec = None + if spec: + if spec.origin is None: + return True + try: + if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)): + return True + except Exception: + return True + return False + + def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any: + importlib.invalidate_caches() + spec = importlib.util.spec_from_file_location( + module_name, + path, + submodule_search_locations=[os.path.dirname(path)] if is_package else None, + ) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {module_name} from {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + def _get_reload_token(self) -> float: + try: + return os.path.getmtime(self._reload_token_path) + except FileNotFoundError: + return 0.0 + except Exception: + return 0.0 + + def _touch_reload_token(self) -> None: + try: + os.makedirs(self.plugins_dir, exist_ok=True) + with open(self._reload_token_path, "a", encoding="utf-8"): + pass + os.utime(self._reload_token_path, None) + except Exception: + logger.debug("Failed to update plugin reload token", exc_info=True) + + def _unload_package(self, package_name: str) -> None: + if not package_name: + return + for name in list(sys.modules.keys()): + if name == package_name or name.startswith(f"{package_name}."): + sys.modules.pop(name, None) + + def _unload_alias(self, alias_name: str) -> None: + if not alias_name: + return + for name in list(sys.modules.keys()): + if name == alias_name or name.startswith(f"{alias_name}."): + sys.modules.pop(name, None) + + def _unload_path_modules(self, path: str) -> None: + if not path: + return + root = os.path.abspath(path) + for name, module in list(sys.modules.items()): + if not module: + continue + mod_path = getattr(module, "__file__", None) + if mod_path: + try: + abs_path = os.path.abspath(mod_path) + if abs_path == root or abs_path.startswith(f"{root}{os.sep}"): + sys.modules.pop(name, None) + continue + except Exception: + pass + mod_paths = getattr(module, "__path__", None) + if mod_paths: + try: + for pkg_path in mod_paths: + abs_pkg = os.path.abspath(pkg_path) + if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"): + sys.modules.pop(name, None) + break + except Exception: + continue diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index cc7b1882..172af265 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -5,15 +5,31 @@ class PluginActionSerializer(serializers.Serializer): id = serializers.CharField() label = serializers.CharField() description = serializers.CharField(required=False, allow_blank=True) + confirm = serializers.JSONField(required=False) + button_label = serializers.CharField(required=False, allow_blank=True) + button_variant = serializers.CharField(required=False, allow_blank=True) + button_color = serializers.CharField(required=False, allow_blank=True) + + +class PluginFieldOptionSerializer(serializers.Serializer): + value = serializers.CharField() + label = serializers.CharField() class PluginFieldSerializer(serializers.Serializer): id = serializers.CharField() - label = serializers.CharField() - type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types + label = serializers.CharField(required=False, allow_blank=True) + type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"]) default = serializers.JSONField(required=False) help_text = serializers.CharField(required=False, allow_blank=True) - options = serializers.ListField(child=serializers.DictField(), required=False) + description = serializers.CharField(required=False, allow_blank=True) + placeholder = serializers.CharField(required=False, allow_blank=True) + input_type = serializers.CharField(required=False, allow_blank=True) + min = serializers.FloatField(required=False) + max = serializers.FloatField(required=False) + step = serializers.FloatField(required=False) + value = serializers.CharField(required=False, allow_blank=True) + options = PluginFieldOptionSerializer(many=True, required=False) class PluginSerializer(serializers.Serializer): @@ -21,8 +37,9 @@ class PluginSerializer(serializers.Serializer): name = serializers.CharField() version = serializers.CharField(allow_blank=True) description = serializers.CharField(allow_blank=True) + author = serializers.CharField(required=False, allow_blank=True) + help_url = serializers.CharField(required=False, allow_blank=True) enabled = serializers.BooleanField() fields = PluginFieldSerializer(many=True) settings = serializers.JSONField() actions = PluginActionSerializer(many=True) - diff --git a/frontend/src/api.js b/frontend/src/api.js index 676e97d8..9cc5494c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1770,6 +1770,7 @@ export default class API { return response?.settings || {}; } catch (e) { errorNotification('Failed to update plugin settings', e); + throw e; } } diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 1293bf7b..186ef7e0 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -1,17 +1,33 @@ -import { NumberInput, Select, Switch, TextInput } from '@mantine/core'; +import { NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core'; import React from 'react'; export const Field = ({ field, value, onChange }) => { - const common = { label: field.label, description: field.help_text }; + const description = field.help_text ?? field.description ?? field.value; + const common = { label: field.label, description }; const effective = value ?? field.default; switch (field.type) { + case 'info': + return ( +
+ {field.label && ( + + {field.label} + + )} + {description && ( + + {description} + + )} +
+ ); case 'boolean': return ( onChange(field.id, e.currentTarget.checked)} label={field.label} - description={field.help_text} + description={description} /> ); case 'number': @@ -19,6 +35,7 @@ export const Field = ({ field, value, onChange }) => { onChange(field.id, v)} + placeholder={field.placeholder} {...common} /> ); @@ -31,6 +48,16 @@ export const Field = ({ field, value, onChange }) => { label: o.label, }))} onChange={(v) => onChange(field.id, v)} + placeholder={field.placeholder} + {...common} + /> + ); + case 'text': + return ( +