diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8f4a3a7..bf5b6040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,13 +201,15 @@ jobs: echo "Creating multi-arch manifest for ${OWNER}/${REPO}" - # branch tag (e.g. latest or dev) + # ghcr.io: both the branch tag (e.g. dev) and the version+timestamp tag + # point to the same manifest by using multiple --tag flags in one call, + # which prevents orphaned untagged images on each new build docker buildx imagetools create \ --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \ + --annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \ --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ --annotation "index:org.opencontainers.image.licenses=See repository" \ @@ -217,9 +219,10 @@ jobs: --annotation "index:maintainer=${{ github.actor }}" \ --annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \ --tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \ + --tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \ ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64 - # version + timestamp tag + # Docker Hub: same single call with both tags docker buildx imagetools create \ --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ @@ -234,40 +237,6 @@ jobs: --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ --annotation "index:maintainer=${{ github.actor }}" \ --annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \ - --tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \ - ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-arm64 - - # also create Docker Hub manifests using the same username - docker buildx imagetools create \ - --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ - --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ - --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \ - --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ - --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ - --annotation "index:org.opencontainers.image.licenses=See repository" \ - --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ - --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ - --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ - --annotation "index:maintainer=${{ github.actor }}" \ - --annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \ --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \ - docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64 - - docker buildx imagetools create \ - --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ - --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ - --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \ - --annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \ - --annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \ - --annotation "index:org.opencontainers.image.licenses=See repository" \ - --annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \ - --annotation "index:org.opencontainers.image.vendor=${OWNER}" \ - --annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \ - --annotation "index:maintainer=${{ github.actor }}" \ - --annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \ --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \ - docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-arm64 + docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64 diff --git a/CHANGELOG.md b/CHANGELOG.md index 63bef9bf..733de71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs: + - **CVE-2025-13473** (low): Username enumeration via timing difference in mod_wsgi authentication handler. + - **CVE-2025-14550** (moderate): Potential denial-of-service via repeated headers on ASGI requests. + - **CVE-2026-1207** (high): Potential SQL injection via raster lookups on PostGIS. + - **CVE-2026-1285** (moderate): Potential denial-of-service in `django.utils.text.Truncator` HTML methods via inputs with large numbers of unmatched HTML end tags. + - **CVE-2026-1287** (high): Potential SQL injection in column aliases via control characters in `FilteredRelation`. + - **CVE-2026-1312** (high): Potential SQL injection via `QuerySet.order_by()` and `FilteredRelation` when using column aliases containing periods. +- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high): + - Updated `ajv` 6.12.6 → 6.14.0, resolving a **moderate** ReDoS vulnerability when using the `$data` option ([GHSA-2g4f-4pwh-qvx6](https://github.com/advisories/GHSA-2g4f-4pwh-qvx6)) + - Enforced `minimatch` ≥10.2.2 via npm overrides, resolving **high** ReDoS via repeated wildcards with non-matching literal patterns ([GHSA-3ppc-4f35-3m26](https://github.com/advisories/GHSA-3ppc-4f35-3m26)) affecting `minimatch`, `@eslint/config-array`, `@eslint/eslintrc`, and `eslint` + +### Added + +- API key authentication: Added support for API key-based authentication as an alternative to JWT tokens. Users can generate and revoke their own personal API key from their profile page, enabling programmatic access for scripts, automations, and third-party integrations without exposing account credentials. Keys authenticate via the `Authorization: ApiKey ` header or the `X-API-Key: ` header. Admin users can additionally generate and revoke keys on behalf of any user. +- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations. +- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942) +- Event-driven webhooks and script execution (Integrations): Added new Integrations feature that enables event-driven execution of custom scripts and webhooks in response to system events. (Closes #203) + - **Supported event types**: channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect) + - **Event data delivery**: available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads + - **Plugin support**: plugins can subscribe to events by specifying an `events` array in their action definitions + - **Connection testing**: test endpoint with dummy payloads for validation before going live + - **Custom HTTP headers**: webhook connections support configurable key/value header pairs + - **Per-event Jinja2 payload templates**: each enabled event can have its own template rendered with the full event payload as context; rendered output is sent as JSON (with `Content-Type: application/json` set automatically) if valid, or as a raw string body otherwise + - **Tabbed connection form**: Settings, Event Triggers, and Payload Templates organized into separate tabs for clarity +- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165) +- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups: + - **Fixed Start Number** (default): Start at a specified number and increment sequentially + - **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing + - **Next Available**: Auto-assign starting from 1, skipping all used channel numbers + Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433) +- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd) +- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup. +- Streamer accounts attempting to log into the web UI now receive a clear notification explaining they cannot access the UI but their stream URLs still work. Previously the login button would silently stop with no feedback. + +### Changed + +- Dependency updates: + - `Django` 5.2.9 → 5.2.11 (security patch; see Security section) + - `celery` 5.6.0 → 5.6.2 + - `psutil` 7.1.3 → 7.2.2 + - `torch` 2.9.1+cpu → 2.10.0+cpu + - `sentence-transformers` 5.2.0 → 5.2.3 + - `ajv` 6.12.6 → 6.14.0 (security patch; see Security section) + - `minimatch` enforced ≥10.2.2 via npm overrides (security patch; see Security section) + - `react` / `react-dom` 19.2.3 → 19.2.4 + - `react-router-dom` / `react-router` 7.12.0 → 7.13.0 + - `react-hook-form` 7.70.0 → 7.71.2 + - `react-draggable` 4.4.6 → 4.5.0 + - `@tanstack/react-table` 8.21.2 → 8.21.3 + - `video.js` 8.23.4 → 8.23.7 + - `vite` 7.3.0 → 7.3.1 + - `zustand` 5.0.9 → 5.0.11 + - `allotment` 1.20.4 → 1.20.5 + - `prettier` 3.7.4 → 3.8.1 + - `@swc/wasm` 1.15.7 → 1.15.11 + - `@testing-library/react` 16.3.1 → 16.3.2 + - `@types/react` 19.2.7 → 19.2.14 + - `@vitejs/plugin-react-swc` 4.2.2 → 4.2.3 +- Channel store optimization: Refactored frontend channel loading to only fetch channel IDs on initial login (matching the streams store pattern), instead of loading full channel objects upfront. Full channel data is fetched lazily as needed. This dramatically reduces login time and initial page load when large channel libraries are present. +- DVR scheduling: Channel selector now displays the channel number alongside the channel name when scheduling a recording. +- TV Guide performance improvements: Optimized the TV Guide with horizontal culling for off-screen program rows (only rendering visible programs), throttled now-line position updates, and improved scroll performance. Reduces unnecessary DOM work and improves responsiveness with large EPG datasets. +- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity. +- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible. +- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839) +- XC API `get_series` now includes `tmdb_id` and `imdb_id` fields, matching `get_vod_streams`. Clients that use TMDB enrichment (e.g. Chillio) can now resolve clean series titles and poster images. - Thanks [@firestaerter3](https://github.com/firestaerter3) +- Stats page "Now Playing" EPG lookup updated to use `channel_uuids` directly (the proxy stats already key active channels by UUID), removing the need for a UUID→integer ID conversion step introduced alongside the lazy channel-fetch refactor. Stream preview sessions (which use a content hash rather than a UUID as their channel ID) are now filtered out before any API call is made, preventing a backend `ValidationError` on both the `current-programs` and `by-uuids` endpoints when a stream preview is active on the Stats page. + +### Fixed + +- Fixed admin permission checks inconsistently using `is_superuser`/`is_staff` instead of `user_level>=10`, causing API-created admin accounts to intermittently see the setup page, lose access to backup endpoints, and miss admin-only notifications. `manage.py createsuperuser` now also correctly sets `user_level=10`. (Fixes #954) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Channel table group filter sort order: The group dropdown in the channel table is now sorted alphabetically. +- DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time. +- XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978) +- Stream Profile form User-Agent not populating when editing: The User-Agent field was not correctly loaded from the existing profile when opening the edit modal. (Fixes #650) +- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique. +- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil) +- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried. + ## [0.19.0] - 2026-02-10 ### Added @@ -80,6 +164,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen) ## [0.18.1] - 2026-01-27 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py index ac062841..90aa84fa 100644 --- a/apps/accounts/admin.py +++ b/apps/accounts/admin.py @@ -7,7 +7,7 @@ from .models import User class CustomUserAdmin(UserAdmin): fieldsets = ( (None, {'fields': ('username', 'password', 'avatar_config', 'groups')}), - ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'user_permissions')}), + ('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}), ('Important dates', {'fields': ('last_login', 'date_joined')}), ) diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py index dda3832c..27de8eb7 100644 --- a/apps/accounts/api_urls.py +++ b/apps/accounts/api_urls.py @@ -4,6 +4,7 @@ from .api_views import ( AuthViewSet, UserViewSet, GroupViewSet, + APIKeyViewSet, TokenObtainPairView, TokenRefreshView, list_permissions, @@ -17,6 +18,7 @@ app_name = "accounts" router = DefaultRouter() router.register(r"users", UserViewSet, basename="user") router.register(r"groups", GroupViewSet, basename="group") +router.register(r"api-keys", APIKeyViewSet, basename="api-key") # 🔹 Custom Authentication Endpoints auth_view = AuthViewSet.as_view({"post": "login"}) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 607ce199..cf2d9225 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -1,4 +1,5 @@ from django.contrib.auth import authenticate, login, logout +import logging from django.contrib.auth.models import Group, Permission from django.http import JsonResponse, HttpResponse from django.views.decorators.csrf import csrf_exempt @@ -8,6 +9,7 @@ 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 +import secrets from .permissions import IsAdmin, Authenticated from dispatcharr.utils import network_access_allowed @@ -15,6 +17,8 @@ from .models import User from .serializers import UserSerializer, GroupSerializer, PermissionSerializer from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +logger = logging.getLogger(__name__) + class TokenObtainPairView(TokenObtainPairView): def post(self, request, *args, **kwargs): @@ -25,6 +29,7 @@ class TokenObtainPairView(TokenObtainPairView): username = request.data.get("username", 'unknown') client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user=username, @@ -43,6 +48,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') try: + logger.debug(f"Attempting JWT login for user={username}") response = super().post(request, *args, **kwargs) # If login was successful, update last_login and log success @@ -61,6 +67,7 @@ class TokenObtainPairView(TokenObtainPairView): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Login success: user={username} ip={client_ip}") except User.DoesNotExist: pass # User doesn't exist, but login somehow succeeded else: @@ -72,6 +79,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent=user_agent, reason='Invalid credentials', ) + logger.info(f"Login failed: user={username} ip={client_ip}") return response @@ -84,6 +92,7 @@ class TokenObtainPairView(TokenObtainPairView): user_agent=user_agent, reason=f'Authentication error: {str(e)[:100]}', ) + logger.error(f"Login error for user={username}: {e}") raise # Re-raise the exception to maintain normal error flow @@ -95,6 +104,7 @@ class TokenRefreshView(TokenRefreshView): from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}") log_system_event( event_type='login_failed', user='token_refresh', @@ -109,8 +119,8 @@ class TokenRefreshView(TokenRefreshView): @csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists. def initialize_superuser(request): - # If a superuser already exists, always indicate that - if User.objects.filter(is_superuser=True).exists(): + # If an admin-level user already exists, the system is configured + if User.objects.filter(user_level__gte=10).exists(): return JsonResponse({"superuser_exists": True}) if request.method == "POST": @@ -167,6 +177,7 @@ class AuthViewSet(viewsets.ViewSet): from core.utils import log_system_event client_ip = request.META.get('REMOTE_ADDR', 'unknown') user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + logger.debug(f"Login attempt via session: user={username} ip={client_ip}") if user: login(request, user) @@ -182,6 +193,7 @@ class AuthViewSet(viewsets.ViewSet): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Login success via session: user={username} ip={client_ip}") return Response( { @@ -203,6 +215,7 @@ class AuthViewSet(viewsets.ViewSet): user_agent=user_agent, reason='Invalid credentials', ) + logger.info(f"Login failed via session: user={username} ip={client_ip}") return Response({"error": "Invalid credentials"}, status=400) @extend_schema( @@ -222,6 +235,7 @@ class AuthViewSet(viewsets.ViewSet): client_ip=client_ip, user_agent=user_agent, ) + logger.info(f"Logout: user={username} ip={client_ip}") logout(request) return Response({"message": "Logout successful"}) @@ -305,6 +319,59 @@ class GroupViewSet(viewsets.ModelViewSet): return super().destroy(request, *args, **kwargs) +# API Key management +class APIKeyViewSet(viewsets.ViewSet): + permission_classes = [Authenticated] + + def list(self, request): + user = request.user + return Response({"key": user.api_key}) + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + raw = secrets.token_urlsafe(40) + target_user.api_key = raw + target_user.save(update_fields=["api_key"]) + + user_data = UserSerializer(target_user).data + return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["post"], url_path="revoke") + def revoke(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + target_user.api_key = None + target_user.save(update_fields=["api_key"]) + + return Response({"success": True}) + + # 🔹 4) Permissions List API @extend_schema( description="Retrieve a list of all permissions", diff --git a/apps/accounts/authentication.py b/apps/accounts/authentication.py new file mode 100644 index 00000000..5380af49 --- /dev/null +++ b/apps/accounts/authentication.py @@ -0,0 +1,49 @@ +from rest_framework import authentication +from rest_framework import exceptions +from django.conf import settings +from .models import User + + +class ApiKeyAuthentication(authentication.BaseAuthentication): + """ + Accepts header `Authorization: ApiKey ` or `X-API-Key: `. + """ + + keyword = "ApiKey" + + def authenticate(self, request): + # Check X-API-Key header first + raw_key = request.META.get("HTTP_X_API_KEY") + + if not raw_key: + auth = authentication.get_authorization_header(request).split() + if not auth: + return None + + if len(auth) != 2: + return None + + scheme = auth[0].decode().lower() + if scheme != self.keyword.lower(): + return None + + raw_key = auth[1].decode() + + if not raw_key: + return None + + if not raw_key: + return None + + try: + user = User.objects.get(api_key=raw_key) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid API key") + + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return (user, None) + + def authenticate_header(self, request): + return self.keyword diff --git a/apps/accounts/migrations/0004_user_api_key.py b/apps/accounts/migrations/0004_user_api_key.py new file mode 100644 index 00000000..fee7c983 --- /dev/null +++ b/apps/accounts/migrations/0004_user_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-21 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_user_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='api_key', + field=models.CharField(blank=True, db_index=True, max_length=200, null=True), + ), + ] diff --git a/apps/accounts/migrations/0005_alter_user_managers.py b/apps/accounts/migrations/0005_alter_user_managers.py new file mode 100644 index 00000000..af66e123 --- /dev/null +++ b/apps/accounts/migrations/0005_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +import apps.accounts.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_user_api_key'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', apps.accounts.models.CustomUserManager()), + ], + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index da5e36bc..e04d66ed 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,9 +1,16 @@ # apps/accounts/models.py from django.db import models -from django.contrib.auth.models import AbstractUser, Permission +from django.contrib.auth.models import AbstractUser, Permission, UserManager + + +class CustomUserManager(UserManager): + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('user_level', 10) + return super().create_superuser(username, email, password, **extra_fields) class User(AbstractUser): + objects = CustomUserManager() """ Custom user model for Dispatcharr. Inherits from Django's AbstractUser to add additional fields if needed. @@ -22,6 +29,7 @@ class User(AbstractUser): ) user_level = models.IntegerField(default=UserLevel.STREAMER) custom_properties = models.JSONField(default=dict, blank=True, null=True) + api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) def __str__(self): return self.username diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 865d29af..05e11ebb 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -28,19 +28,20 @@ class UserSerializer(serializers.ModelSerializer): channel_profiles = serializers.PrimaryKeyRelatedField( queryset=ChannelProfile.objects.all(), many=True, required=False ) + api_key = serializers.CharField(read_only=True, allow_null=True) class Meta: model = User fields = [ "id", "username", + "api_key", "email", "user_level", "password", "channel_profiles", "custom_properties", "avatar_config", - "is_active", "is_staff", "is_superuser", "last_login", @@ -54,7 +55,6 @@ class UserSerializer(serializers.ModelSerializer): user = User(**validated_data) user.set_password(validated_data["password"]) - user.is_active = True user.save() user.channel_profiles.set(channel_profiles) diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 00000000..629207ba --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,72 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +User = get_user_model() + + +class InitializeSuperuserTests(TestCase): + """Tests for the initialize_superuser endpoint""" + + def setUp(self): + self.client = APIClient() + self.url = "/api/accounts/initialize-superuser/" + + def test_returns_true_when_superuser_exists(self): + """Superuser with is_superuser=True should be detected""" + User.objects.create_superuser( + username="admin", password="testpass123", user_level=10 + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_true_when_admin_level_user_exists(self): + """User with user_level=10 but is_superuser=False should be detected""" + user = User.objects.create_user(username="admin", password="testpass123") + user.user_level = 10 + user.is_superuser = False + user.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_false_when_no_admin_exists(self): + """No admin or superuser should return false""" + # Create a non-admin user + User.objects.create_user(username="regular", password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_returns_false_when_no_users_exist(self): + """Empty database should return false""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_create_superuser_when_none_exists(self): + """POST should create superuser when none exists""" + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123", "email": "admin@test.com"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists()) + + def test_cannot_create_superuser_when_admin_exists(self): + """POST should fail when an admin-level user already exists""" + user = User.objects.create_user(username="existing", password="testpass123") + user.user_level = 10 + user.save() + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + # Should NOT have created a new user + self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file diff --git a/apps/api/urls.py b/apps/api/urls.py index 5a688778..b176bc1b 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')), path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')), path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')), + path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')), # path('output/', include(('apps.output.api_urls', 'output'), namespace='output')), #path('player/', include(('apps.player.api_urls', 'player'), namespace='player')), #path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')), diff --git a/apps/backups/api_views.py b/apps/backups/api_views.py index c6ff7d26..a5af495e 100644 --- a/apps/backups/api_views.py +++ b/apps/backups/api_views.py @@ -9,7 +9,8 @@ from django.conf import settings from django.http import HttpResponse, StreamingHttpResponse, Http404 from rest_framework import status from rest_framework.decorators import api_view, permission_classes, parser_classes -from rest_framework.permissions import IsAdminUser, AllowAny +from rest_framework.permissions import AllowAny +from apps.accounts.permissions import IsAdmin from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response @@ -33,7 +34,7 @@ def _verify_task_token(task_id: str, token: str) -> bool: @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def list_backups(request): """List all available backup files.""" try: @@ -47,7 +48,7 @@ def list_backups(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def create_backup(request): """Create a new backup (async via Celery).""" try: @@ -86,7 +87,7 @@ def backup_status(request, task_id): ) else: # Fall back to admin auth check - if not request.user.is_authenticated or not request.user.is_staff: + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: return Response( {"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED, @@ -124,7 +125,7 @@ def backup_status(request, task_id): @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def get_download_token(request, filename): """Get a signed token for downloading a backup file.""" try: @@ -168,7 +169,7 @@ def download_backup(request, filename): ) else: # Fall back to admin auth check - if not request.user.is_authenticated or not request.user.is_staff: + if not request.user.is_authenticated or getattr(request.user, 'user_level', 0) < 10: return Response( {"detail": "Authentication required"}, status=status.HTTP_401_UNAUTHORIZED, @@ -230,7 +231,7 @@ def download_backup(request, filename): @api_view(["DELETE"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def delete_backup(request, filename): """Delete a backup file.""" try: @@ -253,7 +254,7 @@ def delete_backup(request, filename): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) @parser_classes([MultiPartParser, FormParser]) def upload_backup(request): """Upload a backup file for restoration.""" @@ -299,7 +300,7 @@ def upload_backup(request): @api_view(["POST"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def restore_backup(request, filename): """Restore from a backup file (async via Celery). WARNING: This will flush the database!""" try: @@ -332,7 +333,7 @@ def restore_backup(request, filename): @api_view(["GET"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def get_schedule(request): """Get backup schedule settings.""" try: @@ -346,7 +347,7 @@ def get_schedule(request): @api_view(["PUT"]) -@permission_classes([IsAdminUser]) +@permission_classes([IsAdmin]) def update_schedule(request): """Update backup schedule settings.""" try: diff --git a/apps/backups/scheduler.py b/apps/backups/scheduler.py index aa7e9bcd..a427757d 100644 --- a/apps/backups/scheduler.py +++ b/apps/backups/scheduler.py @@ -1,9 +1,13 @@ import json import logging -from django_celery_beat.models import PeriodicTask, CrontabSchedule +from django_celery_beat.models import PeriodicTask from core.models import CoreSettings +from core.scheduling import ( + create_or_update_periodic_task, + delete_periodic_task, +) logger = logging.getLogger(__name__) @@ -105,98 +109,25 @@ def _sync_periodic_task() -> None: settings = get_schedule_settings() if not settings["enabled"]: - # Delete the task if it exists - task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first() - if task: - old_crontab = task.crontab - task.delete() - _cleanup_orphaned_crontab(old_crontab) + delete_periodic_task(BACKUP_SCHEDULE_TASK_NAME) logger.info("Backup schedule disabled, removed periodic task") return - # Get old crontab before creating new one - old_crontab = None - try: - old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME) - old_crontab = old_task.crontab - except PeriodicTask.DoesNotExist: - pass - # Check if using cron expression (advanced mode) if settings["cron_expression"]: - # Parse cron expression: "minute hour day month weekday" - try: - parts = settings["cron_expression"].split() - if len(parts) != 5: - raise ValueError("Cron expression must have 5 parts: minute hour day month weekday") - - minute, hour, day_of_month, month_of_year, day_of_week = parts - - crontab, _ = CrontabSchedule.objects.get_or_create( - minute=minute, - hour=hour, - day_of_week=day_of_week, - day_of_month=day_of_month, - month_of_year=month_of_year, - timezone=CoreSettings.get_system_time_zone(), - ) - except Exception as e: - logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}") - raise ValueError(f"Invalid cron expression: {e}") + cron_expr = settings["cron_expression"] else: - # Use simple frequency-based scheduling - # Parse time + # Build a cron expression from simple frequency settings hour, minute = settings["time"].split(":") - - # Build crontab based on frequency - system_tz = CoreSettings.get_system_time_zone() if settings["frequency"] == "daily": - crontab, _ = CrontabSchedule.objects.get_or_create( - minute=minute, - hour=hour, - day_of_week="*", - day_of_month="*", - month_of_year="*", - timezone=system_tz, - ) + cron_expr = f"{minute} {hour} * * *" else: # weekly - crontab, _ = CrontabSchedule.objects.get_or_create( - minute=minute, - hour=hour, - day_of_week=str(settings["day_of_week"]), - day_of_month="*", - month_of_year="*", - timezone=system_tz, - ) + cron_expr = f"{minute} {hour} * * {settings['day_of_week']}" - # Create or update the periodic task - task, created = PeriodicTask.objects.update_or_create( - name=BACKUP_SCHEDULE_TASK_NAME, - defaults={ - "task": "apps.backups.tasks.scheduled_backup_task", - "crontab": crontab, - "enabled": True, - "kwargs": json.dumps({"retention_count": settings["retention_count"]}), - }, + create_or_update_periodic_task( + task_name=BACKUP_SCHEDULE_TASK_NAME, + celery_task_path="apps.backups.tasks.scheduled_backup_task", + kwargs={"retention_count": settings["retention_count"]}, + cron_expression=cron_expr, + enabled=True, ) - - # Clean up old crontab if it changed and is orphaned - if old_crontab and old_crontab.id != crontab.id: - _cleanup_orphaned_crontab(old_crontab) - - action = "Created" if created else "Updated" - logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}") - - -def _cleanup_orphaned_crontab(crontab_schedule): - """Delete old CrontabSchedule if no other tasks are using it.""" - if crontab_schedule is None: - return - - # Check if any other tasks are using this crontab - if PeriodicTask.objects.filter(crontab=crontab_schedule).exists(): - logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting") - return - - logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}") - crontab_schedule.delete() diff --git a/apps/backups/tests.py b/apps/backups/tests.py index 85076a85..e5743623 100644 --- a/apps/backups/tests.py +++ b/apps/backups/tests.py @@ -735,6 +735,94 @@ class BackupAPITestCase(TestCase): self.assertIn('frequency', data['detail']) +class BackupAdminPermissionTestCase(TestCase): + """Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks. + + This validates the IsAdminUser -> IsAdmin permission change. + API-created admins have user_level=10 but is_staff=False and is_superuser=False. + """ + + def setUp(self): + self.client = APIClient() + # API-created admin: user_level=10 but NOT is_staff or is_superuser + self.api_admin = User.objects.create_user( + username='api_admin', + email='apiadmin@example.com', + password='testpass123' + ) + self.api_admin.user_level = 10 + self.api_admin.is_staff = False + self.api_admin.is_superuser = False + self.api_admin.save() + + # User with is_staff=True but low user_level (should NOT have access) + self.staff_user = User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123' + ) + self.staff_user.is_staff = True + self.staff_user.user_level = 1 + self.staff_user.save() + + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.list_backups') + def test_api_created_admin_can_list_backups(self, mock_list_backups): + """API-created admin (user_level=10, is_staff=False) should access backup endpoints""" + mock_list_backups.return_value = [] + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + + def test_staff_user_without_user_level_cannot_list_backups(self): + """User with is_staff=True but user_level < 10 should NOT access backup endpoints""" + auth_header = self.get_auth_header(self.staff_user) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_api_created_admin_can_create_backup(self, mock_create_task): + """API-created admin should be able to create backups""" + mock_task = MagicMock() + mock_task.id = 'test-task-id' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + + @patch('apps.backups.services.get_backup_dir') + def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir): + """API-created admin should be able to delete backups""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.delete( + '/api/backups/test-backup.zip/delete/', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 204) + + class BackupSchedulerTestCase(TestCase): """Test cases for backup scheduler""" diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index ad5a270f..d0fb3d97 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -724,6 +724,104 @@ class ChannelViewSet(viewsets.ModelViewSet): "channels": serialized_channels }) + @extend_schema( + methods=["POST"], + description=( + "Bulk rename channel names using a regex find/replace executed server-side. " + "Accepts JavaScript-style named groups (e.g., (?...)) and converts them to Python syntax. " + "Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $ are translated to Python." + ), + request=inline_serializer( + name="BulkRegexRenameRequest", + fields={ + "channel_ids": serializers.ListField(child=serializers.IntegerField()), + "find": serializers.CharField(), + "replace": serializers.CharField(required=False, allow_blank=True), + "flags": serializers.CharField(required=False, allow_blank=True), + }, + ), + ) + @action(detail=False, methods=["post"], url_path="edit/bulk-regex") + def bulk_regex_rename(self, request): + """ + Efficiently apply a regex find/replace to the `name` field of multiple channels. + """ + import regex as re + + channel_ids = request.data.get("channel_ids", []) + pattern = request.data.get("find", "") + replace = request.data.get("replace", "") + flags_str = request.data.get("flags", "") or "" + + if not isinstance(channel_ids, list) or len(channel_ids) == 0: + return Response({"error": "channel_ids must be a non-empty list"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(pattern, str) or pattern.strip() == "": + return Response({"error": "find (regex pattern) is required"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(replace, str): + return Response({"error": "replace must be a string"}, status=status.HTTP_400_BAD_REQUEST) + + # Convert JS-style named groups to Python (?...) -> (?P...) + try: + converted_pattern = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", pattern) + except Exception as e: + return Response({"error": f"Failed to normalize pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST) + + # Compile flags + re_flags = 0 + if "i" in flags_str: + re_flags |= re.IGNORECASE + # Note: 'g' (global) is the default behavior of re.sub; no action needed. + + # Translate common JS replacement tokens to Python + def translate_js_replacement(rep: str) -> str: + # $$ -> $ + rep = rep.replace("$$", "$") + # $& -> \g<0> + rep = rep.replace("$&", r"\g<0>") + # $ -> \g + rep = re.sub(r"\$<([A-Za-z_][A-Za-z0-9_]*)>", r"\\g<\1>", rep) + # $1 -> \g<1>, $2 -> \g<2>, etc. + rep = re.sub(r"\$(\d+)", r"\\g<\1>", rep) + return rep + + try: + replacement_py = translate_js_replacement(replace) + compiled = re.compile(converted_pattern, flags=re_flags) + except Exception as e: + return Response({"error": f"Invalid regex pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST) + + # Fetch channels in one query + channels = list(Channel.objects.filter(id__in=channel_ids)) + if not channels: + return Response({"error": "No matching channels found for provided IDs"}, status=status.HTTP_404_NOT_FOUND) + + changed = [] + for ch in channels: + current = ch.name or "" + try: + new_name = compiled.sub(replacement_py, current) + except Exception as e: + # Skip problematic replacements but continue processing others + logger.warning(f"Regex replacement failed for channel {ch.id}: {e}") + continue + + # Only update if name actually changes and remains non-empty + if new_name != current and new_name.strip(): + ch.name = new_name + changed.append(ch) + + # Apply updates in bulk + updated_count = 0 + if changed: + with transaction.atomic(): + Channel.objects.bulk_update(changed, fields=["name"], batch_size=100) + updated_count = len(changed) + + return Response({ + "success": True, + "updated_count": updated_count, + }, status=status.HTTP_200_OK) + @action(detail=False, methods=["post"], url_path="set-names-from-epg") def set_names_from_epg(self, request): """ @@ -831,6 +929,50 @@ class ChannelViewSet(viewsets.ModelViewSet): # Return the response with the list of IDs return Response(list(channel_ids)) + @action(detail=False, methods=["get"], url_path="summary") + def summary(self, request, *args, **kwargs): + """Return a lightweight list of channels with only the fields needed by the TV Guide.""" + queryset = self.filter_queryset(self.get_queryset()) + data = list( + queryset.values( + "id", + "name", + "logo_id", + "channel_number", + "uuid", + "epg_data_id", + "channel_group_id", + ) + ) + return Response(data) + + @extend_schema( + methods=["POST"], + description="Retrieve channels by a list of UUIDs using POST to avoid URL length limitations", + request=inline_serializer( + name="ChannelByUUIDsRequest", + fields={ + "uuids": serializers.ListField( + child=serializers.CharField(), + help_text="List of channel UUIDs to retrieve", + ) + }, + ), + responses={200: ChannelSerializer(many=True)}, + ) + @action(detail=False, methods=["post"], url_path="by-uuids") + def get_by_uuids(self, request, *args, **kwargs): + uuids = request.data.get("uuids", []) + if not isinstance(uuids, list): + return Response( + {"error": "uuids must be a list of strings"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + channels = Channel.objects.filter(uuid__in=uuids) + serializer = self.get_serializer(channels, many=True) + return Response(serializer.data) + @extend_schema( methods=["POST"], description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", diff --git a/apps/channels/signals.py b/apps/channels/signals.py index 27b361ba..75ef16dd 100644 --- a/apps/channels/signals.py +++ b/apps/channels/signals.py @@ -85,12 +85,23 @@ def create_profile_memberships(sender, instance, created, **kwargs): for channel in channels ]) -def schedule_recording_task(instance): - eta = instance.start_time +def schedule_recording_task(instance, eta=None): + # Use the explicitly-passed (and timezone-aware) eta if provided; + # fall back to instance.start_time only as a last resort. + if eta is None: + eta = instance.start_time + # Ensure eta is timezone-aware before comparing against now() + if eta is not None and not is_aware(eta): + eta = make_aware(eta) + # countdown=0 fires immediately (in-progress programs whose start_time was + # clamped to now by the serializer), countdown>0 delays until start_time + # (future programs). Using an integer countdown avoids any timezone + # serialization ambiguity that can occur with an absolute eta datetime. + countdown = max(0, int((eta - now()).total_seconds())) # Pass recording_id first so task can persist metadata to the correct row task = run_recording.apply_async( args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)], - eta=eta + countdown=countdown, ) return task.id @@ -133,7 +144,10 @@ def schedule_task_on_save(sender, instance, created, **kwargs): # Optionally allow slight fudge factor (1 second) to ensure scheduling happens if start_time > current_time - timedelta(seconds=1): print("Scheduling recording task!") - task_id = schedule_recording_task(instance) + # Pass the corrected, timezone-aware start_time explicitly so + # schedule_recording_task uses it as the Celery ETA rather than + # re-reading instance.start_time which may still be naive. + task_id = schedule_recording_task(instance, eta=start_time) instance.task_id = task_id instance.save(update_fields=['task_id']) else: diff --git a/apps/connect/__init__.py b/apps/connect/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/api_urls.py b/apps/connect/api_urls.py new file mode 100644 index 00000000..664c437b --- /dev/null +++ b/apps/connect/api_urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from .api_views import ( + IntegrationViewSet, + EventSubscriptionViewSet, + DeliveryLogViewSet, +) + +app_name = 'connect' + +router = DefaultRouter() +router.register(r'integrations', IntegrationViewSet, basename='integration') +router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription') +router.register(r'logs', DeliveryLogViewSet, basename='delivery-log') + +urlpatterns = [] +urlpatterns += router.urls diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py new file mode 100644 index 00000000..739e8e32 --- /dev/null +++ b/apps/connect/api_views.py @@ -0,0 +1,198 @@ +from rest_framework import viewsets, status +from rest_framework.pagination import PageNumberPagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.response import Response +from rest_framework.decorators import action +from django.utils import timezone +from .models import Integration, EventSubscription, DeliveryLog +from .serializers import ( + IntegrationSerializer, + EventSubscriptionSerializer, + DeliveryLogSerializer, +) +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, + IsAdmin, +) +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler + + +class IntegrationViewSet(viewsets.ModelViewSet): + queryset = Integration.objects.all() + serializer_class = IntegrationSerializer + + def get_permissions(self): + try: + perms = permission_classes_by_action[self.action] + except KeyError: + # Respect view/action-specific permission_classes if provided; fallback to Authenticated + perms = getattr(self, "permission_classes", [Authenticated]) + return [perm() for perm in perms] + + @action(detail=True, methods=["get"], url_path="subscriptions") + def list_subscriptions(self, request, pk=None): + qs = EventSubscription.objects.filter(integration_id=pk) + serializer = EventSubscriptionSerializer(qs, many=True) + return Response(serializer.data) + + @action(detail=True, methods=["put"], url_path=r"subscriptions/set") + def set_subscriptions(self, request, pk=None): + """ + Replace the integration's subscriptions with the provided list. + Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...] + Any existing subscriptions not in the list will be deleted; missing ones will be created/updated. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response( + {"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND + ) + + data = request.data + if not isinstance(data, list): + return Response( + {"detail": "Expected a list of subscriptions"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate incoming items using serializer (without integration field) + # We'll attach the integration explicitly + valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES) + incoming = [] + for item in data: + if not isinstance(item, dict): + return Response( + {"detail": "Each subscription must be an object"}, + status=status.HTTP_400_BAD_REQUEST, + ) + event = item.get("event") + if event not in valid_events: + return Response( + {"detail": f"Invalid event: {event}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Only accept payload_template when the integration is a webhook + payload_template = item.get("payload_template") if integration.type == "webhook" else None + incoming.append( + { + "event": event, + "enabled": bool(item.get("enabled", True)), + "payload_template": payload_template, + } + ) + + incoming_events = {s["event"] for s in incoming} + + # Delete subscriptions that are no longer present + EventSubscription.objects.filter(integration=integration).exclude( + event__in=incoming_events + ).delete() + + # Upsert incoming subscriptions + updated = [] + for sub in incoming: + obj, _created = EventSubscription.objects.update_or_create( + integration=integration, + event=sub["event"], + defaults={ + "enabled": sub["enabled"], + "payload_template": sub.get("payload_template"), + }, + ) + updated.append(obj) + + serializer = EventSubscriptionSerializer(updated, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin]) + def test(self, request, pk=None): + """ + Execute a saved integration (connect) with a dummy payload to verify configuration. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND) + + # Build a dummy payload similar to system events + now = timezone.now().isoformat() + dummy_payload = { + "event": "test", + "timestamp": now, + "channel_name": "Test Channel", + "stream_name": "Test Stream", + "stream_url": "http://example.com/stream.m3u8", + "channel_url": "http://example.com/stream.m3u8", + "provider_name": "Test Provider", + "profile_used": "Default", + "test": True, + } + + # Choose handler based on saved type + if integration.type == "webhook": + handler = WebhookHandler(integration, None, dummy_payload) + elif integration.type == "script": + handler = ScriptHandler(integration, None, dummy_payload) + else: + return Response( + {"success": False, "error": f"Unsupported integration type: {integration.type}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + result = handler.execute() + return Response( + { + "success": bool(result.get("success")), + "type": integration.type, + "request_payload": dummy_payload, + "result": result, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + { + "success": False, + "type": integration.type, + "request_payload": dummy_payload, + "error": str(e), + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + + +class EventSubscriptionViewSet(viewsets.ModelViewSet): + queryset = EventSubscription.objects.all() + serializer_class = EventSubscriptionSerializer + + +class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet): + queryset = DeliveryLog.objects.all().order_by("-created_at") + serializer_class = DeliveryLogSerializer + filter_backends = [DjangoFilterBackend] + + # Support server-side pagination with page_size query param + class ConnectLogsPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 250 + + pagination_class = ConnectLogsPagination + + def get_queryset(self): + qs = super().get_queryset() + + # Optional filters: integration id and type + integration_id = self.request.query_params.get("integration") + if integration_id: + qs = qs.filter(subscription__integration_id=integration_id) + + integration_type = self.request.query_params.get("type") + if integration_type: + qs = qs.filter(subscription__integration__type=integration_type) + + return qs diff --git a/apps/connect/apps.py b/apps/connect/apps.py new file mode 100644 index 00000000..db063fa4 --- /dev/null +++ b/apps/connect/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ConnectConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.connect' + verbose_name = "Connect Integrations" + label = 'dispatcharr_connect' diff --git a/apps/connect/handlers/__init__.py b/apps/connect/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/api.py b/apps/connect/handlers/api.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/handlers/base.py b/apps/connect/handlers/base.py new file mode 100644 index 00000000..d61b7c53 --- /dev/null +++ b/apps/connect/handlers/base.py @@ -0,0 +1,12 @@ +# connect/handlers/base.py +import abc + +class IntegrationHandler(abc.ABC): + def __init__(self, integration, subscription, payload): + self.integration = integration + self.subscription = subscription + self.payload = payload + + @abc.abstractmethod + def execute(self): + pass diff --git a/apps/connect/handlers/script.py b/apps/connect/handlers/script.py new file mode 100644 index 00000000..b6aef79c --- /dev/null +++ b/apps/connect/handlers/script.py @@ -0,0 +1,81 @@ +# connect/handlers/script.py +import os +import stat +import subprocess +from django.conf import settings +from .base import IntegrationHandler + + +def _is_path_allowed(real_path: str) -> bool: + # Ensure path is within one of the allowed directories + for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []): + base_abs = os.path.abspath(base) + os.sep + if real_path.startswith(base_abs): + return True + return False + + +class ScriptHandler(IntegrationHandler): + def execute(self): + raw_path = self.integration.config.get("path") + if not raw_path: + raise ValueError("Missing 'path' in integration config") + + # Resolve and validate path + real_path = os.path.abspath(os.path.realpath(raw_path)) + + if not os.path.exists(real_path): + raise FileNotFoundError(f"Script not found: {real_path}") + + if not _is_path_allowed(real_path): + raise PermissionError( + f"Script path '{real_path}' not within allowed directories: " + f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}" + ) + + if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True): + if not os.access(real_path, os.X_OK): + raise PermissionError(f"Script is not executable: {real_path}") + + if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True): + st = os.stat(real_path) + if st.st_mode & stat.S_IWOTH: + raise PermissionError( + f"Refusing to execute world-writable script: {real_path}" + ) + + # Build a sanitized minimal environment; avoid inheriting secrets + env = { + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + for key, value in (self.payload or {}).items(): + env_key = f"DISPATCHARR_{str(key).upper()}" + env[env_key] = "" if value is None else str(value) + + # Run with a timeout to prevent hanging scripts + timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10) + max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536) + + result = subprocess.run( + [real_path], + capture_output=True, + text=True, + env=env, + timeout=timeout, + cwd=os.path.dirname(real_path) or None, + ) + + # Truncate outputs to avoid excessive memory/logging + stdout = result.stdout or "" + stderr = result.stderr or "" + if len(stdout) > max_out: + stdout = stdout[:max_out] + "... [truncated]" + if len(stderr) > max_out: + stderr = stderr[:max_out] + "... [truncated]" + + return { + "exit_code": result.returncode, + "stdout": stdout, + "stderr": stderr, + "success": result.returncode == 0, + } diff --git a/apps/connect/handlers/webhook.py b/apps/connect/handlers/webhook.py new file mode 100644 index 00000000..511f0a0e --- /dev/null +++ b/apps/connect/handlers/webhook.py @@ -0,0 +1,21 @@ +# connect/handlers/webhook.py +import requests, json, logging +from .base import IntegrationHandler + +logger = logging.getLogger(__name__) + +class WebhookHandler(IntegrationHandler): + def execute(self): + url = self.integration.config.get("url") + headers = self.integration.config.get("headers", {}) + logger.info(self.payload) + + try: + parsed = json.loads(self.payload) + headers["Content-Type"] = "application/json" + except Exception: + pass + + response = requests.post(url, data=self.payload, headers=headers, timeout=10) + + return {"status_code": response.status_code, "body": response.text, "success": response.ok} diff --git a/apps/connect/migrations/0001_initial.py b/apps/connect/migrations/0001_initial.py new file mode 100644 index 00000000..cd8f6b42 --- /dev/null +++ b/apps/connect/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-01-27 21:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EventSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)), + ('enabled', models.BooleanField(default=True)), + ('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)), + ], + ), + migrations.CreateModel( + name='Integration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)), + ('config', models.JSONField(default=dict)), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='DeliveryLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)), + ('request_payload', models.JSONField(blank=True, default=dict)), + ('response_payload', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')), + ], + ), + migrations.AddField( + model_name='eventsubscription', + name='integration', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'), + ), + ] diff --git a/apps/connect/migrations/0002_alter_eventsubscription_event.py b/apps/connect/migrations/0002_alter_eventsubscription_event.py new file mode 100644 index 00000000..90eee90b --- /dev/null +++ b/apps/connect/migrations/0002_alter_eventsubscription_event.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_connect', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='eventsubscription', + name='event', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100), + ), + ] diff --git a/apps/connect/migrations/__init__.py b/apps/connect/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/connect/models.py b/apps/connect/models.py new file mode 100644 index 00000000..0b33a85e --- /dev/null +++ b/apps/connect/models.py @@ -0,0 +1,47 @@ +from django.db import models + +SUPPORTED_EVENTS = { + "channel_start": "Channel Started", + "channel_stop": "Channel Stopped", + "channel_reconnect": "Channel Reconnected", + "channel_error": "Channel Error", + "channel_failover": "Channel Failover", + "stream_switch": "Stream Switch", + "recording_start": "Recording Started", + "recording_end": "Recording Ended", + "epg_refresh": "EPG Refreshed", + "m3u_refresh": "M3U Refreshed", + "client_connect": "Client Connected", + "client_disconnect": "Client Disconnected", + "login_failed": "Login Failed", + "epg_blocked": "EPG Blocked", + "m3u_blocked": "M3U Blocked", +} + +class Integration(models.Model): + TYPE_CHOICES = [ + ("webhook", "Webhook"), + ("api", "API"), + ("script", "Custom Script"), + ] + name = models.CharField(max_length=255) + type = models.CharField(max_length=50, choices=TYPE_CHOICES) + config = models.JSONField(default=dict) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class EventSubscription(models.Model): + EVENT_CHOICES = list(SUPPORTED_EVENTS.items()) + event = models.CharField(max_length=100, choices=EVENT_CHOICES) + integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions") + enabled = models.BooleanField(default=True) + payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload") + +class DeliveryLog(models.Model): + subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs") + status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")]) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/connect/serializers.py b/apps/connect/serializers.py new file mode 100644 index 00000000..832fc14b --- /dev/null +++ b/apps/connect/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers +from .models import Integration, EventSubscription, DeliveryLog +import os + + +class EventSubscriptionSerializer(serializers.ModelSerializer): + class Meta: + model = EventSubscription + fields = [ + "id", + "event", + "enabled", + "payload_template", + "integration", + ] + + +class IntegrationSerializer(serializers.ModelSerializer): + subscriptions = EventSubscriptionSerializer(many=True, read_only=True) + + class Meta: + model = Integration + fields = [ + "id", + "name", + "type", + "config", + "enabled", + "created_at", + "subscriptions", + ] + + def validate(self, attrs): + type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None) + config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {}) + + if type == "script": + path = (config or {}).get("path") + if not path or not isinstance(path, str): + raise serializers.ValidationError({"config": "Script config must include a 'path' string"}) + + real_path = os.path.abspath(os.path.realpath(path)) + if not os.path.exists(real_path): + raise serializers.ValidationError({"config": f"Script path does not exist: {path}"}) + elif type == "webhook": + url = (config or {}).get("url") + if not url or not isinstance(url, str): + raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"}) + else: + raise serializers.ValidationError({"type": "Unsupported integration type"}) + + return attrs + + +class DeliveryLogSerializer(serializers.ModelSerializer): + subscription = EventSubscriptionSerializer(read_only=True) + + class Meta: + model = DeliveryLog + fields = [ + "id", + "subscription", + "status", + "request_payload", + "response_payload", + "error_message", + "created_at", + ] diff --git a/apps/connect/utils.py b/apps/connect/utils.py new file mode 100644 index 00000000..c3a0975f --- /dev/null +++ b/apps/connect/utils.py @@ -0,0 +1,116 @@ +# connect/utils.py +import logging, json +from django.template import Template, Context +from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler +from apps.plugins.loader import PluginManager + +logger = logging.getLogger(__name__) + +HANDLERS = { + "webhook": WebhookHandler, + "script": ScriptHandler, +} + + +def trigger_event(event_name, payload): + if event_name not in SUPPORTED_EVENTS: + logger.debug(f"Unsupported event '{event_name}' - skipping") + return + + logger.debug( + f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}" + ) + subscriptions = EventSubscription.objects.filter( + event=event_name, enabled=True + ).select_related("integration") + + count = subscriptions.count() + logger.info(f"Found {count} connect subscription(s) for event '{event_name}'") + + # First, fetch all subscriptions and trigger + for sub in subscriptions: + integration = sub.integration + if not integration.enabled: + logger.debug( + f"Skipping disabled integration id={integration.id} name={integration.name}" + ) + continue + + # apply optional payload template (only for webhook integrations) + # If the rendered template is valid JSON, use that object as the payload. + # Otherwise, pass the rendered string as-is. + final_payload = payload + if integration.type == 'webhook' and sub.payload_template: + try: + template = Template(sub.payload_template) + final_payload = template.render(Context(payload)).strip() + except Exception as e: + logger.error( + f"Payload template render failed for subscription id={sub.id}: {e}" + ) + final_payload = payload + + handler_cls = HANDLERS.get(integration.type) + if not handler_cls: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=f"No handler for integration type '{integration.type}'", + ) + logger.error( + f"No handler for integration type '{integration.type}' (integration id={integration.id})" + ) + continue + + handler = handler_cls(integration, sub, final_payload) + logger.debug( + f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}" + ) + + try: + result = handler.execute() + DeliveryLog.objects.create( + subscription=sub, + status="success" if result.get("success") else "failed", + request_payload=final_payload, + response_payload=result, + ) + logger.info( + f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'" + ) + except Exception as e: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=str(e), + ) + logger.error( + f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}" + ) + + pm = PluginManager.get() + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + + logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'") + for plugin in plugins: + if not plugin["enabled"]: + logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}") + continue + + logger.debug(json.dumps(plugin)) + for action in plugin["actions"]: + if "events" in action and event_name in action["events"]: + key = plugin["key"] + params = {"event": event_name, "payload": payload} + action_name = action.get("label") or action.get("id") + action_id = action.get("id") + logger.debug( + f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}" + ) + if action_id: + pm.run_action(key, action_id, params) diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py index 00f7403f..dbae6394 100644 --- a/apps/epg/api_views.py +++ b/apps/epg/api_views.py @@ -31,7 +31,9 @@ class EPGSourceViewSet(viewsets.ModelViewSet): API endpoint that allows EPG sources to be viewed or edited. """ - queryset = EPGSource.objects.all() + queryset = EPGSource.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).all() serializer_class = EPGSourceSerializer def get_permissions(self): @@ -439,43 +441,32 @@ class CurrentProgramsAPIView(APIView): request=inline_serializer( name="CurrentProgramsRequest", fields={ - "channel_ids": serializers.ListField( - child=serializers.IntegerField(), + "channel_uuids": serializers.ListField( + child=serializers.CharField(), required=False, allow_null=True, - help_text="Array of channel IDs. If null or omitted, returns all channels with current programs.", + help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.", ), }, ), responses={200: ProgramDataSerializer(many=True)}, ) def post(self, request, format=None): - # Get channel IDs from request body - channel_ids = request.data.get('channel_ids', None) - # Import Channel model from apps.channels.models import Channel # Build query for channels with EPG data query = Channel.objects.filter(epg_data__isnull=False) - # Filter by specific channel IDs if provided - if channel_ids is not None: - if not isinstance(channel_ids, list): + channel_uuids = request.data.get('channel_uuids', None) + + if channel_uuids is not None: + if not isinstance(channel_uuids, list): return Response( - {"error": "channel_ids must be an array of integers or null"}, + {"error": "channel_uuids must be an array of strings or null"}, status=status.HTTP_400_BAD_REQUEST ) - - try: - channel_ids = [int(id) for id in channel_ids] - except (ValueError, TypeError): - return Response( - {"error": "channel_ids must contain valid integers"}, - status=status.HTTP_400_BAD_REQUEST - ) - - query = query.filter(id__in=channel_ids) + query = query.filter(uuid__in=channel_uuids) # Get channels with EPG data channels = query.select_related('epg_data') @@ -495,9 +486,8 @@ class CurrentProgramsAPIView(APIView): ).first() if program: - # Serialize program and add channel_id for easy mapping program_data = ProgramDataSerializer(program).data - program_data['channel_id'] = channel.id + program_data['channel_uuid'] = str(channel.uuid) current_programs.append(program_data) return Response(current_programs, status=status.HTTP_200_OK) diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py index e4d5f466..07775809 100644 --- a/apps/epg/serializers.py +++ b/apps/epg/serializers.py @@ -12,6 +12,7 @@ class EPGSourceSerializer(serializers.ModelSerializer): allow_null=True, validators=[validate_flexible_url] ) + cron_expression = serializers.CharField(required=False, allow_blank=True, default='') class Meta: model = EPGSource @@ -24,6 +25,7 @@ class EPGSourceSerializer(serializers.ModelSerializer): 'is_active', 'file_path', 'refresh_interval', + 'cron_expression', 'priority', 'status', 'last_message', @@ -37,6 +39,42 @@ class EPGSourceSerializer(serializers.ModelSerializer): """Return the count of EPG data entries instead of all IDs to prevent large payloads""" return obj.epgs.count() + def to_representation(self, instance): + data = super().to_representation(instance) + # Derive cron_expression from the linked PeriodicTask's crontab (single source of truth) + # But first check if we have a transient _cron_expression (from create/update before signal runs) + cron_expr = '' + if hasattr(instance, '_cron_expression'): + cron_expr = instance._cron_expression + elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' + data['cron_expression'] = cron_expr + return data + + def update(self, instance, validated_data): + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if 'cron_expression' in validated_data: + cron_expr = validated_data.pop('cron_expression') + else: + cron_expr = '' + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' + instance._cron_expression = cron_expr + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + + def create(self, validated_data): + cron_expr = validated_data.pop('cron_expression', '') + instance = EPGSource(**validated_data) + instance._cron_expression = cron_expr + instance.save() + return instance + class ProgramDataSerializer(serializers.ModelSerializer): class Meta: model = ProgramData diff --git a/apps/epg/signals.py b/apps/epg/signals.py index e41d3aaf..97992ef3 100644 --- a/apps/epg/signals.py +++ b/apps/epg/signals.py @@ -2,7 +2,7 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from .models import EPGSource, EPGData from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id -from django_celery_beat.models import PeriodicTask, IntervalSchedule +from core.scheduling import create_or_update_periodic_task, delete_periodic_task from core.utils import is_protected_path, send_websocket_update import json import logging @@ -70,10 +70,11 @@ def create_dummy_epg_data(sender, instance, created, **kwargs): logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})") @receiver(post_save, sender=EPGSource) -def create_or_update_refresh_task(sender, instance, **kwargs): +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): """ Create or update a Celery Beat periodic task when an EPGSource is created/updated. Skip creating tasks for dummy EPG sources as they don't need refreshing. + Supports both interval-based and cron-based scheduling via the shared utility. """ # Skip task creation for dummy EPGs if instance.source_type == 'dummy': @@ -83,39 +84,48 @@ def create_or_update_refresh_task(sender, instance, **kwargs): instance.refresh_task.save(update_fields=['enabled']) return + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return + task_name = f"epg_source-refresh-{instance.id}" - interval, _ = IntervalSchedule.objects.get_or_create( - every=int(instance.refresh_interval), - period=IntervalSchedule.HOURS + should_be_enabled = instance.is_active + + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass + + task = create_or_update_periodic_task( + task_name=task_name, + celery_task_path="apps.epg.tasks.refresh_epg_data", + kwargs={"source_id": instance.id}, + interval_hours=int(instance.refresh_interval), + cron_expression=cron_expr, + enabled=should_be_enabled, ) - task, created = PeriodicTask.objects.get_or_create(name=task_name, defaults={ - "interval": interval, - "task": "apps.epg.tasks.refresh_epg_data", - "kwargs": json.dumps({"source_id": instance.id}), - "enabled": instance.refresh_interval != 0 and instance.is_active, - }) - - update_fields = [] - if created: - task.interval = interval - - if task.interval != interval: - task.interval = interval - update_fields.append("interval") - - # Check both refresh_interval and is_active to determine if task should be enabled - should_be_enabled = instance.refresh_interval != 0 and instance.is_active - if task.enabled != should_be_enabled: - task.enabled = should_be_enabled - update_fields.append("enabled") - - if update_fields: - task.save(update_fields=update_fields) - if instance.refresh_task != task: instance.refresh_task = task - instance.save(update_fields=["refresh_task"]) # Fixed field name + instance.save(update_fields=["refresh_task"]) @receiver(post_delete, sender=EPGSource) def delete_refresh_task(sender, instance, **kwargs): diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py index 97552171..9dc597d3 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -24,7 +24,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from .models import EPGSource, EPGData, ProgramData -from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event +from core.utils import acquire_task_lock, release_task_lock, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event logger = logging.getLogger(__name__) @@ -146,12 +146,15 @@ def refresh_all_epg_data(): return "EPG data refreshed." -@shared_task +@shared_task(time_limit=1800, soft_time_limit=1700) def refresh_epg_data(source_id): if not acquire_task_lock('refresh_epg_data', source_id): logger.debug(f"EPG refresh for {source_id} already running") return + lock_renewer = TaskLockRenewer('refresh_epg_data', source_id) + lock_renewer.start() + source = None try: # Try to get the EPG source @@ -168,6 +171,7 @@ def refresh_epg_data(source_id): logger.info(f"No orphaned task found for EPG source {source_id}") # Release the lock and exit + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -176,6 +180,7 @@ def refresh_epg_data(source_id): # The source exists but is not active, just skip processing if not source.is_active: logger.info(f"EPG source {source_id} is not active. Skipping.") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -184,6 +189,7 @@ def refresh_epg_data(source_id): # Skip refresh for dummy EPG sources - they don't need refreshing if source.source_type == 'dummy': logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) gc.collect() return @@ -194,6 +200,7 @@ def refresh_epg_data(source_id): fetch_success = fetch_xmltv(source) if not fetch_success: logger.error(f"Failed to fetch XMLTV for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -202,6 +209,7 @@ def refresh_epg_data(source_id): parse_channels_success = parse_channels_only(source) if not parse_channels_success: logger.error(f"Failed to parse channels for source {source.name}") + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) # Force garbage collection before exit gc.collect() @@ -234,6 +242,7 @@ def refresh_epg_data(source_id): source = None # Force garbage collection before releasing the lock gc.collect() + lock_renewer.stop() release_task_lock('refresh_epg_data', source_id) @@ -1126,12 +1135,15 @@ def parse_channels_only(source): -@shared_task +@shared_task(time_limit=3600, soft_time_limit=3500) def parse_programs_for_tvg_id(epg_id): if not acquire_task_lock('parse_epg_programs', epg_id): logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") return "Task already running" + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + source_file = None program_parser = None programs_to_create = [] @@ -1161,11 +1173,13 @@ def parse_programs_for_tvg_id(epg_id): # Skip program parsing for dummy EPG sources - they don't have program data files if epg_source.source_type == 'dummy': logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return if not Channel.objects.filter(epg_data=epg).exists(): logger.info(f"No channels matched to EPG {epg.tvg_id}") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1207,6 +1221,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, cannot parse programs" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1217,6 +1232,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"Failed to download EPG data, file missing after download" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1232,6 +1248,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source.last_message = f"No URL provided, cannot fetch EPG data" epg_source.save(update_fields=['status', 'last_message']) send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided") + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) return @@ -1379,7 +1396,7 @@ def parse_programs_for_tvg_id(epg_id): epg_source = None # Add comprehensive cleanup before releasing lock cleanup_memory(log_usage=should_log_memory, force_collection=True) - # Memory tracking after processing + # Memory tracking after processing if process: try: mem_after = process.memory_info().rss / 1024 / 1024 @@ -1389,6 +1406,7 @@ def parse_programs_for_tvg_id(epg_id): process = None epg = None programs_processed = None + lock_renewer.stop() release_task_lock('parse_epg_programs', epg_id) diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py index 73331f7a..26e182d9 100644 --- a/apps/m3u/api_views.py +++ b/apps/m3u/api_views.py @@ -37,7 +37,9 @@ import json class M3UAccountViewSet(viewsets.ModelViewSet): """Handles CRUD operations for M3U accounts""" - queryset = M3UAccount.objects.prefetch_related("channel_group") + queryset = M3UAccount.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).prefetch_related("channel_group") serializer_class = M3UAccountSerializer def get_permissions(self): diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index a607dc07..22c4057a 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -139,6 +139,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True) auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True) auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True) + cron_expression = serializers.CharField(required=False, allow_blank=True, default="") class Meta: model = M3UAccount @@ -158,6 +159,7 @@ class M3UAccountSerializer(serializers.ModelSerializer): "locked", "channel_groups", "refresh_interval", + "cron_expression", "custom_properties", "account_type", "username", @@ -188,9 +190,30 @@ class M3UAccountSerializer(serializers.ModelSerializer): data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True) data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True) data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True) + + # Derive cron_expression from the linked PeriodicTask's crontab (single source of truth) + # But first check if we have a transient _cron_expression (from create/update before signal runs) + cron_expr = "" + if hasattr(instance, '_cron_expression'): + cron_expr = instance._cron_expression + elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + data["cron_expression"] = cron_expr return data def update(self, instance, validated_data): + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if "cron_expression" in validated_data: + cron_expr = validated_data.pop("cron_expression") + else: + cron_expr = "" + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + instance._cron_expression = cron_expr + # Handle enable_vod preference and auto_enable_new_groups settings enable_vod = validated_data.pop("enable_vod", None) auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None) @@ -244,6 +267,9 @@ class M3UAccountSerializer(serializers.ModelSerializer): return instance def create(self, validated_data): + # Pop cron_expression — it's not a model field + cron_expr = validated_data.pop("cron_expression", "") + # Handle enable_vod preference and auto_enable_new_groups settings during creation enable_vod = validated_data.pop("enable_vod", False) auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True) @@ -260,7 +286,11 @@ class M3UAccountSerializer(serializers.ModelSerializer): custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series validated_data["custom_properties"] = custom_props - return super().create(validated_data) + # Build instance manually so we can attach transient attr before save triggers signal + instance = M3UAccount(**validated_data) + instance._cron_expression = cron_expr + instance.save() + return instance def get_filters(self, obj): filters = obj.filters.order_by("order") diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py index d014ac92..3de67c90 100644 --- a/apps/m3u/signals.py +++ b/apps/m3u/signals.py @@ -3,7 +3,7 @@ from django.db.models.signals import post_save, post_delete, pre_save from django.dispatch import receiver from .models import M3UAccount from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id -from django_celery_beat.models import PeriodicTask, IntervalSchedule +from core.scheduling import create_or_update_periodic_task, delete_periodic_task import json import logging @@ -20,51 +20,53 @@ def refresh_account_on_save(sender, instance, created, **kwargs): refresh_m3u_groups.delay(instance.id) @receiver(post_save, sender=M3UAccount) -def create_or_update_refresh_task(sender, instance, **kwargs): +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): """ Create or update a Celery Beat periodic task when an M3UAccount is created/updated. + Supports both interval-based and cron-based scheduling via the shared utility. """ - task_name = f"m3u_account-refresh-{instance.id}" + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return - interval, _ = IntervalSchedule.objects.get_or_create( - every=int(instance.refresh_interval), - period=IntervalSchedule.HOURS + task_name = f"m3u_account-refresh-{instance.id}" + should_be_enabled = instance.is_active + + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass + + task = create_or_update_periodic_task( + task_name=task_name, + celery_task_path="apps.m3u.tasks.refresh_single_m3u_account", + kwargs={"account_id": instance.id}, + interval_hours=int(instance.refresh_interval), + cron_expression=cron_expr, + enabled=should_be_enabled, ) - # Task should be enabled only if refresh_interval != 0 AND account is active - should_be_enabled = (instance.refresh_interval != 0) and instance.is_active - - # First check if the task already exists to avoid validation errors - try: - task = PeriodicTask.objects.get(name=task_name) - # Task exists, just update it - updated_fields = [] - - if task.enabled != should_be_enabled: - task.enabled = should_be_enabled - updated_fields.append("enabled") - - if task.interval != interval: - task.interval = interval - updated_fields.append("interval") - - if updated_fields: - task.save(update_fields=updated_fields) - - # Ensure instance has the task - if instance.refresh_task_id != task.id: - M3UAccount.objects.filter(id=instance.id).update(refresh_task=task) - - except PeriodicTask.DoesNotExist: - # Create new task if it doesn't exist - refresh_task = PeriodicTask.objects.create( - name=task_name, - interval=interval, - task="apps.m3u.tasks.refresh_single_m3u_account", - kwargs=json.dumps({"account_id": instance.id}), - enabled=should_be_enabled, - ) - M3UAccount.objects.filter(id=instance.id).update(refresh_task=refresh_task) + # Ensure instance has the task linked + if instance.refresh_task_id != task.id: + M3UAccount.objects.filter(id=instance.id).update(refresh_task=task) @receiver(post_delete, sender=M3UAccount) def delete_refresh_task(sender, instance, **kwargs): diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index f929a208..a9413143 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -23,6 +23,7 @@ from core.utils import ( RedisClient, acquire_task_lock, release_task_lock, + TaskLockRenewer, natural_sort_key, log_system_event, ) @@ -66,7 +67,8 @@ def fetch_m3u_lines(account, use_cache=False): account.save(update_fields=["status", "last_message"]) response = requests.get( - account.server_url, headers=headers, stream=True + account.server_url, headers=headers, stream=True, + timeout=(30, 60), # 30s connect, 60s read between chunks ) # Log the actual response details for debugging @@ -126,119 +128,60 @@ def fetch_m3u_lines(account, use_cache=False): start_time = time.time() last_update_time = start_time progress = 0 - temp_content = b"" # Store content temporarily to validate before saving has_content = False - # First, let's collect the content and validate it - send_m3u_update(account.id, "downloading", 0) - for chunk in response.iter_content(chunk_size=8192): - if chunk: - temp_content += chunk - has_content = True + # Stream directly to a temp file to avoid holding the entire + # M3U in memory (large files can be 100MB+, which would use + # ~3x that in RAM in certain approaches). + temp_path = file_path + ".tmp" + try: + send_m3u_update(account.id, "downloading", 0) + with open(temp_path, "wb") as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + has_content = True - downloaded += len(chunk) - elapsed_time = time.time() - start_time + downloaded += len(chunk) + elapsed_time = time.time() - start_time - # Calculate download speed in KB/s - speed = downloaded / elapsed_time / 1024 # in KB/s + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 # in KB/s - # Calculate progress percentage - if total_size and total_size > 0: - progress = (downloaded / total_size) * 100 + # Calculate progress percentage + if total_size and total_size > 0: + progress = (downloaded / total_size) * 100 - # Time remaining (in seconds) - time_remaining = ( - (total_size - downloaded) / (speed * 1024) - if speed > 0 - else 0 - ) - - current_time = time.time() - if current_time - last_update_time >= 0.5: - last_update_time = current_time - if progress > 0: - # Update the account's last_message with detailed progress info - progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" - account.last_message = progress_msg - account.save(update_fields=["last_message"]) - - send_m3u_update( - account.id, - "downloading", - progress, - speed=speed, - elapsed_time=elapsed_time, - time_remaining=time_remaining, - message=progress_msg, + # Time remaining (in seconds) + time_remaining = ( + (total_size - downloaded) / (speed * 1024) + if speed > 0 + else 0 ) - # Check if we actually received any content - logger.info(f"Download completed. Has content: {has_content}, Content length: {len(temp_content)} bytes") - if not has_content or len(temp_content) == 0: - error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}" - 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, - "downloading", - 100, - status="error", - error=error_msg, - ) - return [], False + current_time = time.time() + if current_time - last_update_time >= 0.5: + last_update_time = current_time + if progress > 0: + # Update the account's last_message with detailed progress info + progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" + account.last_message = progress_msg + account.save(update_fields=["last_message"]) - # Basic validation: check if content looks like an M3U file - try: - content_str = temp_content.decode('utf-8', errors='ignore') - content_lines = content_str.strip().split('\n') + send_m3u_update( + account.id, + "downloading", + progress, + speed=speed, + elapsed_time=elapsed_time, + time_remaining=time_remaining, + message=progress_msg, + ) - # Log first few lines for debugging (be careful not to log too much) - preview_lines = content_lines[:5] - logger.info(f"Content preview (first 5 lines): {preview_lines}") - logger.info(f"Total lines in content: {len(content_lines)}") - - # Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content) - is_valid_m3u = False - - # First, check if this looks like an error response disguised as 200 OK - content_lower = content_str.lower() - if any(error_indicator in content_lower for error_indicator in [ - '= 2: - # Extract username and password from path - transformed_username = path_parts[1] - transformed_password = path_parts[2] + if len(path_parts) >= 4 and path_parts[-1] == '1234.ts': + # Extract username and password from the known structure: + # .../{live}/{username}/{password}/1234.ts + # Using negative indices so sub-paths in the server URL don't shift extraction + transformed_username = path_parts[-3] + transformed_password = path_parts[-2] # Rebuild server URL without the username/password path transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}" @@ -2542,12 +2630,18 @@ def refresh_account_info(profile_id): release_task_lock("refresh_account_info", profile_id) return error_msg -@shared_task +@shared_task(time_limit=3600, soft_time_limit=3500) def refresh_single_m3u_account(account_id): """Splits M3U processing into chunks and dispatches them as parallel tasks.""" if not acquire_task_lock("refresh_single_m3u_account", account_id): return f"Task already running for account_id={account_id}." + # Keep the lock alive while this long-running task is working. + # Without renewal, the 300s lock TTL can expire during large + # downloads/parses, allowing duplicate tasks to start. + lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id) + lock_renewer.start() + # Record start time refresh_start_timestamp = timezone.now() # For the cleanup function start_time = time.time() # For tracking elapsed time as float @@ -2559,6 +2653,7 @@ def refresh_single_m3u_account(account_id): account = M3UAccount.objects.get(id=account_id, is_active=True) if not account.is_active: logger.debug(f"Account {account_id} is not active, skipping.") + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return @@ -2588,6 +2683,7 @@ def refresh_single_m3u_account(account_id): else: logger.debug(f"No orphaned task found for M3U account {account_id}") + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up" @@ -2638,6 +2734,7 @@ def refresh_single_m3u_account(account_id): logger.error( f"Failed to refresh M3U groups for account {account_id}: {result}" ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account - download failed or other error" @@ -2671,6 +2768,7 @@ def refresh_single_m3u_account(account_id): status="error", error=f"Error refreshing M3U groups: {str(e)}", ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account" @@ -2694,6 +2792,7 @@ def refresh_single_m3u_account(account_id): status="error", error="No data available for processing", ) + lock_renewer.stop() release_task_lock("refresh_single_m3u_account", account_id) return "Failed to update m3u account, no data available" @@ -3004,8 +3103,9 @@ def refresh_single_m3u_account(account_id): account.last_message = f"Error processing M3U: {str(e)}" account.save(update_fields=["status", "last_message"]) raise # Re-raise the exception for Celery to handle - - release_task_lock("refresh_single_m3u_account", account_id) + finally: + lock_renewer.stop() + release_task_lock("refresh_single_m3u_account", account_id) # Aggressive garbage collection # Only delete variables if they exist diff --git a/apps/output/views.py b/apps/output/views.py index 1d53237a..e99c424b 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -183,8 +183,9 @@ def generate_m3u(request, profile_name=None, user=None): # Check if this is an XC API request (has username/password in GET params and user is authenticated) xc_username = request.GET.get('username') xc_password = request.GET.get('password') + is_xc_request = user is not None and xc_username and xc_password - if user is not None and xc_username and xc_password: + if is_xc_request: # This is an XC API request - use XC-style EPG URL base_url = build_absolute_uri_with_port(request, '') epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" @@ -254,8 +255,12 @@ def generate_m3u(request, profile_name=None, user=None): f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' ) - # Determine the stream URL based on the direct parameter - if use_direct_urls: + # Determine the stream URL based on request type + if is_xc_request: + # XC API request - use XC-style stream URL format + base_url = build_absolute_uri_with_port(request, '') + stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" + elif use_direct_urls: # Try to get the first stream's direct URL first_stream = channel.streams.order_by('channelstream__order').first() if first_stream and first_stream.url: @@ -506,6 +511,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone program_duration = custom_properties.get('program_duration', 180) # Minutes title_template = custom_properties.get('title_template', '') + subtitle_template = custom_properties.get('subtitle_template', '') description_template = custom_properties.get('description_template', '') # Templates for upcoming/ended programs @@ -911,6 +917,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust title_parts.append(all_groups['title']) main_event_title = ' - '.join(title_parts) if title_parts else channel_name + if subtitle_template: + main_event_subtitle = format_template(subtitle_template, all_groups) + else: + main_event_subtitle = None + if description_template: main_event_description = format_template(description_template, all_groups) else: @@ -961,6 +972,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": upcoming_title, + "sub_title": None, # No subtitle for filler programs "description": upcoming_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1000,6 +1012,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": event_start_utc, "end_time": event_end_utc, "title": main_event_title, + "sub_title": main_event_subtitle, "description": main_event_description, "custom_properties": main_event_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1044,6 +1057,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": ended_title, + "sub_title": None, # No subtitle for filler programs "description": ended_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1104,6 +1118,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": program_title, + "sub_title": None, # No subtitle for filler programs "description": program_description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, @@ -1131,6 +1146,11 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust title_parts.append(all_groups['title']) title = ' - '.join(title_parts) if title_parts else channel_name + if subtitle_template: + subtitle = format_template(subtitle_template, all_groups) + else: + subtitle = None + if description_template: description = format_template(description_template, all_groups) else: @@ -1167,6 +1187,7 @@ def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, cust "start_time": program_start_utc, "end_time": program_end_utc, "title": title, + "sub_title": subtitle, "description": description, "custom_properties": program_custom_properties, "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation @@ -1206,6 +1227,11 @@ def generate_dummy_epg( f' ' ) xml_lines.append(f" {html.escape(program['title'])}") + + # Add subtitle if available + if program.get('sub_title'): + xml_lines.append(f" {html.escape(program['sub_title'])}") + xml_lines.append(f" {html.escape(program['description'])}") # Add custom_properties if present @@ -1525,6 +1551,11 @@ def generate_epg(request, profile_name=None, user=None): # Create program entry with escaped channel name yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1574,6 +1605,11 @@ def generate_epg(request, profile_name=None, user=None): yield f' \n' yield f" {html.escape(program['title'])}\n" + + # Add subtitle if available + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" # Add custom_properties if present @@ -1909,6 +1945,7 @@ def xc_get_user(request): return None user = get_object_or_404(User, username=username) + custom_properties = user.custom_properties or {} if "xc_password" not in custom_properties: @@ -2208,7 +2245,7 @@ def xc_get_live_streams(request, user, category_id=None): ) ), "epg_channel_id": str(channel_num_int), - "added": int(channel.created_at.timestamp()), + "added": str(int(channel.created_at.timestamp())), "is_adult": int(channel.is_adult), "category_id": str(channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id), "category_ids": [channel.channel_group.id if channel.channel_group else ChannelGroup.objects.get_or_create(name="Default Group")[0].id], @@ -2523,6 +2560,8 @@ def xc_get_series(request, user, category_id=None): "episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "", "category_id": str(relation.category.id) if relation.category else "0", "category_ids": [int(relation.category.id)] if relation.category else [], + "tmdb_id": series.tmdb_id or "", + "imdb_id": series.imdb_id or "", }) return series_list @@ -2870,7 +2909,7 @@ def xc_get_vod_info(request, user, vod_id): "movie_data": { "stream_id": movie.id, "name": movie.name, - "added": int(movie_relation.created_at.timestamp()), + "added": str(int(movie_relation.created_at.timestamp())), "category_id": str(movie_relation.category.id) if movie_relation.category else "0", "category_ids": [int(movie_relation.category.id)] if movie_relation.category else [], "container_extension": movie_relation.container_extension or "mp4", diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py index 172af265..9f568054 100644 --- a/apps/plugins/serializers.py +++ b/apps/plugins/serializers.py @@ -9,6 +9,9 @@ class PluginActionSerializer(serializers.Serializer): 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) + events = serializers.ListField( + child=serializers.CharField(), required=False, allow_empty=True + ) class PluginFieldOptionSerializer(serializers.Serializer): diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index aafc75cd..c4c2130f 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -459,13 +459,12 @@ class VODConnectionManager: return False try: - # Check profile connection limits using standardized key - if not self._check_profile_limits(m3u_profile): + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): logger.warning(f"Profile {m3u_profile.name} connection limit exceeded") return False connection_key = self._get_connection_key(content_type, content_uuid, client_id) - profile_connections_key = self._get_profile_connections_key(m3u_profile.id) content_connections_key = self._get_content_connections_key(content_type, content_uuid) # Check if connection already exists to prevent duplicate counting @@ -473,6 +472,9 @@ class VODConnectionManager: logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}") # Update activity but don't increment profile counter self.redis_client.hset(connection_key, "last_activity", str(time.time())) + # Roll back the reservation — connection already counted + if m3u_profile.max_streams > 0: + self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id)) return True # Connection data @@ -499,8 +501,7 @@ class VODConnectionManager: pipe.hset(connection_key, mapping=connection_data) pipe.expire(connection_key, self.connection_ttl) - # Increment profile connections using standardized method - pipe.incr(profile_connections_key) + # Profile counter already incremented atomically above — no pipe.incr needed # Add to content connections set pipe.sadd(content_connections_key, client_id) @@ -513,6 +514,9 @@ class VODConnectionManager: return True except Exception as e: + # Roll back the profile reservation on failure + if m3u_profile.max_streams > 0: + self.redis_client.decr(self._get_profile_connections_key(m3u_profile.id)) logger.error(f"Error creating VOD connection: {e}") return False @@ -531,6 +535,41 @@ class VODConnectionManager: logger.error(f"Error checking profile limits: {e}") return False + def _check_and_reserve_profile_slot(self, m3u_profile: M3UAccountProfile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + def update_connection_activity(self, content_type: str, content_uuid: str, client_id: str, bytes_sent: int = 0, position_seconds: int = 0) -> bool: diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 1534f761..74c5db41 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -416,8 +416,22 @@ class RedisBackedVODConnection: logger.info(f"[{self.session_id}] Updated connection state: length={state.content_length}, type={state.content_type}") - # Save updated state - self._save_connection_state(state) + # Save updated state under lock to avoid overwriting concurrent + # active_streams changes (e.g., another stream's GeneratorExit decrement) + if self._acquire_lock(): + try: + current = self._get_connection_state() + if current: + # Preserve the current active_streams value — it may have been + # modified by concurrent increment/decrement operations while + # waiting for the upstream HTTP response. + state.active_streams = current.active_streams + self._save_connection_state(state) + finally: + self._release_lock() + else: + # Fallback: save without lock but skip active_streams to avoid overwrite + logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save") self.local_response = response return response @@ -466,35 +480,44 @@ class RedisBackedVODConnection: return range_header def increment_active_streams(self): - """Increment active streams count in Redis""" + """Increment active streams count in Redis. Returns new active_streams count, or 0 on failure.""" if not self._acquire_lock(): - return False + logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock") + return 0 try: state = self._get_connection_state() if state: + old = state.active_streams state.active_streams += 1 state.last_activity = time.time() self._save_connection_state(state) - logger.debug(f"[{self.session_id}] Active streams incremented to {state.active_streams}") - return True - return False + logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}") + return state.active_streams + logger.warning(f"[{self.session_id}] INCR-AS failed: no state") + return 0 finally: self._release_lock() def decrement_active_streams(self): """Decrement active streams count in Redis""" if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock") return False try: state = self._get_connection_state() if state and state.active_streams > 0: + old = state.active_streams state.active_streams -= 1 state.last_activity = time.time() self._save_connection_state(state) - logger.debug(f"[{self.session_id}] Active streams decremented to {state.active_streams}") + logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}") return True + if not state: + logger.warning(f"[{self.session_id}] DECR-AS failed: no state") + else: + logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}") return False finally: self._release_lock() @@ -674,6 +697,41 @@ class MultiWorkerVODConnectionManager: logger.error(f"Error checking profile limits: {e}") return False + def _check_and_reserve_profile_slot(self, m3u_profile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + def _increment_profile_connections(self, m3u_profile): """Increment profile connection count""" try: @@ -756,10 +814,11 @@ class MultiWorkerVODConnectionManager: if not existing_state: logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection") - # Check profile limits before creating new connection - if not self._check_profile_limits(m3u_profile): + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded") return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True # Apply timeshift parameters modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset) @@ -802,16 +861,43 @@ class MultiWorkerVODConnectionManager: worker_id=self.worker_id ): logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection") + # Roll back the profile slot reservation since connection failed + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False return HttpResponse("Failed to create connection", status=500) - # Increment profile connections after successful connection creation - self._increment_profile_connections(m3u_profile) - profile_connections_incremented = True - logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata") else: logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection") + # Immediately increment active_streams to prevent cleanup race condition. + # Without this, stream's GeneratorExit can see active_streams=0 + # and DECR the profile counter before the new generator starts. + if matching_session_id: + # Idle session reuse: active_streams already incremented at line 776 + # Always need to re-reserve profile slot (GeneratorExit DECRed it) + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse") + redis_connection.decrement_active_streams() + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + else: + # Concurrent/reconnect: increment active_streams now (not in generator) + new_count = redis_connection.increment_active_streams() + if new_count == 1: + # 0→1 transition: previous stream's GeneratorExit already DECRed + # the profile counter, need to re-reserve the slot + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect") + redis_connection.decrement_active_streams() + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + elif new_count == 0: + logger.error(f"[{client_id}] Failed to increment active streams") + return HttpResponse("Failed to reserve stream", status=500) + # else: new_count > 1, another stream is already active and profile + # counter already reflects it — no INCR needed + # Transfer ownership to current worker and update session activity if redis_connection._acquire_lock(): try: @@ -834,6 +920,12 @@ class MultiWorkerVODConnectionManager: if upstream_response is None: logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable") + if existing_state: + # Roll back the active_streams increment from the else branch + redis_connection.decrement_active_streams() + if profile_connections_incremented: + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False return HttpResponse("Requested Range Not Satisfiable", status=416) # Get connection headers @@ -846,13 +938,14 @@ class MultiWorkerVODConnectionManager: try: logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream") - # Increment active streams (unless we already did it for session reuse) - if not matching_session_id: - # New session - increment active streams + # Increment active streams only for brand-new connections. + # For existing connections (session reuse or concurrent requests), + # active_streams was already incremented in the else branch above + # to prevent cleanup race conditions with GeneratorExit. + if not existing_state: redis_connection.increment_active_streams() else: - # Reused session - we already incremented when reserving the session - logger.debug(f"[{client_id}] Using pre-reserved session - active streams already incremented") + logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path") bytes_sent = 0 chunk_count = 0 @@ -898,11 +991,19 @@ class MultiWorkerVODConnectionManager: # Schedule smart cleanup if no active streams after normal completion if not redis_connection.has_active_streams(): + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on normal completion") + def delayed_cleanup(): time.sleep(1) # Wait 1 second # Smart cleanup: check active streams and ownership logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion") - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) import threading cleanup_thread = threading.Thread(target=delayed_cleanup) @@ -917,11 +1018,19 @@ class MultiWorkerVODConnectionManager: # Schedule smart cleanup if no active streams if not redis_connection.has_active_streams(): + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on client disconnect") + def delayed_cleanup(): time.sleep(1) # Wait 1 second # Smart cleanup: check active streams and ownership logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect") - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) import threading cleanup_thread = threading.Thread(target=delayed_cleanup) @@ -933,8 +1042,17 @@ class MultiWorkerVODConnectionManager: if not decremented: redis_connection.decrement_active_streams() decremented = True - # Smart cleanup on error - immediate cleanup since we're in error state - redis_connection.cleanup(connection_manager=self, current_worker_id=self.worker_id) + + # Decrement profile counter immediately if no other active streams + if not redis_connection.has_active_streams(): + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on stream error") + # Smart cleanup on error - immediate cleanup since we're in error state + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) yield b"Error: Stream interrupted" finally: diff --git a/apps/vod/admin.py b/apps/vod/admin.py index c660f310..6dadab3e 100644 --- a/apps/vod/admin.py +++ b/apps/vod/admin.py @@ -60,7 +60,7 @@ class M3USeriesRelationAdmin(admin.ModelAdmin): @admin.register(M3UEpisodeRelation) class M3UEpisodeRelationAdmin(admin.ModelAdmin): - list_display = ['episode', 'm3u_account', 'stream_id', 'created_at'] + list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at'] list_filter = ['m3u_account', 'created_at'] search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] readonly_fields = ['created_at', 'updated_at'] diff --git a/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py new file mode 100644 index 00000000..e4973627 --- /dev/null +++ b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-24 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='series_relation', + field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'), + ), + ] diff --git a/apps/vod/models.py b/apps/vod/models.py index 7067856e..dd7d1753 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -261,6 +261,14 @@ class M3UEpisodeRelation(models.Model): """Links M3U accounts to Episodes with provider-specific information""" m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) # Streaming information (provider-specific) stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index c24023d6..1b12c7b7 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -1258,20 +1258,15 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N else: episodes_data = {} - # Clear existing episodes for this account to handle deletions - Episode.objects.filter( - series=series, - m3u_relations__m3u_account=account - ).delete() + # Fetch the series relation once — used both to pass into batch_process_episodes + # (so episode relations get the FK set) and to update metadata afterwards. + series_relation = M3USeriesRelation.objects.filter( + m3u_account=account, + external_series_id=external_series_id + ).first() # Process all episodes in batch - batch_process_episodes(account, series, episodes_data) - - # Update the series relation to mark episodes as fetched - series_relation = M3USeriesRelation.objects.filter( - series=series, - m3u_account=account - ).first() + batch_process_episodes(account, series, episodes_data, series_relation=series_relation) if series_relation: custom_props = series_relation.custom_properties or {} @@ -1285,13 +1280,18 @@ def refresh_series_episodes(account, series, external_series_id, episodes_data=N logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}") -def batch_process_episodes(account, series, episodes_data, scan_start_time=None): +def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None): """Process episodes in batches for better performance. Note: Multiple streams can represent the same episode (e.g., different languages or qualities). Each stream has a unique stream_id, but they share the same season/episode number. We create one Episode record per (series, season, episode) and multiple M3UEpisodeRelation records pointing to it. + + series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so + that CASCADE correctly removes episode relations when their parent series relation + is deleted, and so that stale-stream cleanup is scoped precisely to relations that + came from this specific provider query. """ if not episodes_data: return @@ -1445,10 +1445,11 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Update existing relation relation = existing_relations[episode_id] relation.episode = episode + relation.series_relation = series_relation relation.container_extension = episode_data.get('container_extension', 'mp4') relation.custom_properties = { 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, } relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan relations_to_update.append(relation) @@ -1457,11 +1458,12 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) relation = M3UEpisodeRelation( m3u_account=account, episode=episode, + series_relation=series_relation, stream_id=episode_id, container_extension=episode_data.get('container_extension', 'mp4'), custom_properties={ 'info': episode_data, - 'season_number': season_number + 'season_number': season_number, }, last_seen=scan_start_time or timezone.now() # Mark as seen during this scan ) @@ -1524,9 +1526,28 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # Update existing episode relations if relations_to_update: M3UEpisodeRelation.objects.bulk_update(relations_to_update, [ - 'episode', 'container_extension', 'custom_properties', 'last_seen' + 'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen' ]) + # Delete relations for streams no longer returned by the provider. + # Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows + # for the same account+series (pre-migration rows whose stream is now gone — the + # update path only backfills the FK for streams still present in the response). + # Falls back to account+series scope when series_relation is None (shouldn't occur). + if series_relation is not None: + stale_qs = M3UEpisodeRelation.objects.filter( + Q(series_relation=series_relation) | + Q(series_relation__isnull=True, m3u_account=account, episode__series=series) + ) + else: + stale_qs = M3UEpisodeRelation.objects.filter( + m3u_account=account, + episode__series=series + ) + removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0] + if removed_count: + logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}") + logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, " f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations") @@ -1611,16 +1632,12 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= stale_movie_count = stale_movie_relations.count() stale_movie_relations.delete() - # Clean up stale series relations + # Clean up stale series relations. + # Episode relations are removed via CASCADE on the series_relation FK. stale_series_relations = M3USeriesRelation.objects.filter(**base_filters) stale_series_count = stale_series_relations.count() stale_series_relations.delete() - # Clean up stale episode relations - stale_episode_relations = M3UEpisodeRelation.objects.filter(**base_filters) - stale_episode_count = stale_episode_relations.count() - stale_episode_relations.delete() - # Clean up movies with no relations (orphaned) # Safe to delete even during account-specific cleanup because if ANY account # has a relation, m3u_relations will not be null @@ -1637,11 +1654,8 @@ def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id= logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations") orphaned_series.delete() - # Episodes will be cleaned up via CASCADE when series are deleted - result = (f"Cleaned up {stale_movie_count} stale movie relations, " f"{stale_series_count} stale series relations, " - f"{stale_episode_count} stale episode relations, " f"{orphaned_movie_count} orphaned movies, and " f"{orphaned_series_count} orphaned series") diff --git a/core/api_views.py b/core/api_views.py index 20ef6249..3d131ab9 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -506,7 +506,7 @@ class SystemNotificationViewSet(viewsets.ModelViewSet): ) # Filter admin-only notifications for non-admins - if not getattr(user, 'is_superuser', False) and getattr(user, 'user_level', 0) < 10: + if getattr(user, 'user_level', 0) < 10: queryset = queryset.filter(admin_only=False) # For developer notifications, evaluate conditions diff --git a/core/developer_notifications.py b/core/developer_notifications.py index cf16e7a2..f4214b43 100644 --- a/core/developer_notifications.py +++ b/core/developer_notifications.py @@ -200,7 +200,7 @@ def should_show_notification(notification_data: dict, user) -> bool: # Check user level user_level = notification_data.get('user_level', 'all') - if user_level == 'admin' and not getattr(user, 'is_superuser', False): + if user_level == 'admin' and getattr(user, 'user_level', 0) < 10: return False # Check conditions @@ -396,7 +396,7 @@ def get_user_developer_notifications(user) -> list: ) # Filter by admin_only based on user - if not getattr(user, 'is_superuser', False): + if getattr(user, 'user_level', 0) < 10: notifications = notifications.filter(admin_only=False) # Filter by conditions diff --git a/core/scheduling.py b/core/scheduling.py new file mode 100644 index 00000000..0b4b78c6 --- /dev/null +++ b/core/scheduling.py @@ -0,0 +1,203 @@ +""" +Reusable scheduling utilities for creating/updating/deleting +Celery Beat periodic tasks with interval or cron-based schedules. +""" + +import json +import logging + +from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask + +from core.models import CoreSettings + +logger = logging.getLogger(__name__) + + +def parse_cron_expression(cron_expression): + """ + Parse a 5-part cron expression into its components. + + Args: + cron_expression: A string like "0 3 * * *" + + Returns: + dict with keys: minute, hour, day_of_month, month_of_year, day_of_week + + Raises: + ValueError: If the expression is not valid 5-part cron. + """ + parts = cron_expression.strip().split() + if len(parts) != 5: + raise ValueError( + "Cron expression must have 5 parts: minute hour day month weekday" + ) + return { + "minute": parts[0], + "hour": parts[1], + "day_of_month": parts[2], + "month_of_year": parts[3], + "day_of_week": parts[4], + } + + +def create_or_update_periodic_task( + task_name, + celery_task_path, + kwargs=None, + interval_hours=0, + cron_expression="", + enabled=True, +): + """ + Create or update a Celery Beat PeriodicTask. Supports both interval + (hours) and cron-based scheduling. + + When *cron_expression* is provided and non-empty it takes precedence + over *interval_hours*. An interval_hours of 0 (with no cron) means + the task is disabled. + + Args: + task_name: Unique PeriodicTask name. + celery_task_path: Dotted path to the Celery task function. + kwargs: dict of keyword arguments passed to the task. + interval_hours: Interval in hours (0 = disabled when no cron). + cron_expression: 5-part cron string (empty = use interval). + enabled: Whether the task should be enabled. + + Returns: + The PeriodicTask instance (created or updated). + """ + task_kwargs = json.dumps(kwargs or {}) + + # Determine effective enabled state + use_cron = bool(cron_expression and cron_expression.strip()) + should_be_enabled = enabled and (use_cron or interval_hours > 0) + + # Retrieve existing task (if any) to track old schedule objects + old_interval = None + old_crontab = None + try: + existing = PeriodicTask.objects.get(name=task_name) + old_interval = existing.interval + old_crontab = existing.crontab + except PeriodicTask.DoesNotExist: + existing = None + + if use_cron: + # ---- Cron-based schedule ---- + cron_parts = parse_cron_expression(cron_expression) + system_tz = CoreSettings.get_system_time_zone() + + crontab, _ = CrontabSchedule.objects.get_or_create( + minute=cron_parts["minute"], + hour=cron_parts["hour"], + day_of_week=cron_parts["day_of_week"], + day_of_month=cron_parts["day_of_month"], + month_of_year=cron_parts["month_of_year"], + timezone=system_tz, + ) + + defaults = { + "task": celery_task_path, + "crontab": crontab, + "interval": None, + "enabled": should_be_enabled, + "kwargs": task_kwargs, + } + + task, created = PeriodicTask.objects.update_or_create( + name=task_name, defaults=defaults + ) + + # Clean up old interval if we switched from interval → cron + if old_interval: + _cleanup_orphaned_interval(old_interval) + # Clean up old crontab if it changed + if old_crontab and old_crontab.id != crontab.id: + _cleanup_orphaned_crontab(old_crontab) + + else: + # ---- Interval-based schedule ---- + interval, _ = IntervalSchedule.objects.get_or_create( + every=max(int(interval_hours), 1) if interval_hours else 1, + period=IntervalSchedule.HOURS, + ) + + defaults = { + "task": celery_task_path, + "interval": interval, + "crontab": None, + "enabled": should_be_enabled, + "kwargs": task_kwargs, + } + + task, created = PeriodicTask.objects.update_or_create( + name=task_name, defaults=defaults + ) + + # Clean up old crontab if we switched from cron → interval + if old_crontab: + _cleanup_orphaned_crontab(old_crontab) + # Clean up old interval if it changed + if old_interval and old_interval.id != interval.id: + _cleanup_orphaned_interval(old_interval) + + action = "Created" if created else "Updated" + mode = "cron" if use_cron else "interval" + logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})") + return task + + +def delete_periodic_task(task_name): + """ + Delete a PeriodicTask by name and clean up orphaned schedules. + + Args: + task_name: The unique name of the PeriodicTask. + + Returns: + True if a task was found and deleted, False otherwise. + """ + try: + task = PeriodicTask.objects.get(name=task_name) + except PeriodicTask.DoesNotExist: + logger.warning(f"No PeriodicTask found with name '{task_name}'") + return False + + old_interval = task.interval + old_crontab = task.crontab + task_id = task.id + + task.delete() + logger.info(f"Deleted periodic task '{task_name}' (id={task_id})") + + if old_interval: + _cleanup_orphaned_interval(old_interval) + if old_crontab: + _cleanup_orphaned_crontab(old_crontab) + + return True + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _cleanup_orphaned_interval(interval_schedule): + """Delete an IntervalSchedule if no PeriodicTasks reference it.""" + if interval_schedule is None: + return + if PeriodicTask.objects.filter(interval=interval_schedule).exists(): + return + logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}") + interval_schedule.delete() + + +def _cleanup_orphaned_crontab(crontab_schedule): + """Delete a CrontabSchedule if no PeriodicTasks reference it.""" + if crontab_schedule is None: + return + if PeriodicTask.objects.filter(crontab=crontab_schedule).exists(): + return + logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}") + crontab_schedule.delete() diff --git a/core/utils.py b/core/utils.py index c0e87a42..a5ea41df 100644 --- a/core/utils.py +++ b/core/utils.py @@ -222,6 +222,75 @@ def release_task_lock(task_name, id): # Remove the lock redis_client.delete(lock_id) + +class TaskLockRenewer: + """Periodically renews a Redis task lock to prevent expiry during long-running tasks. + + Use as a context manager after acquiring a lock: + + if acquire_task_lock("my_task", task_id): + with TaskLockRenewer("my_task", task_id): + # ... long-running work ... + release_task_lock("my_task", task_id) + + A daemon thread extends the lock TTL at regular intervals so that + slow downloads or large parsing jobs don't lose their lock mid-operation. + """ + + def __init__(self, task_name, id, ttl=300, renewal_interval=120): + self.task_name = task_name + self.id = id + self.ttl = ttl + self.renewal_interval = renewal_interval + self.lock_id = f"task_lock_{task_name}_{id}" + self._stop_event = threading.Event() + self._thread = None + + def _renew_loop(self): + """Background loop that extends the lock TTL until stopped.""" + while not self._stop_event.wait(self.renewal_interval): + try: + redis_client = RedisClient.get_client() + if redis_client.exists(self.lock_id): + redis_client.expire(self.lock_id, self.ttl) + logger.debug( + f"Renewed lock {self.lock_id} TTL to {self.ttl}s" + ) + else: + # Lock was deleted externally (e.g. manual release) — stop renewing + logger.warning( + f"Lock {self.lock_id} no longer exists, stopping renewal" + ) + break + except Exception as e: + logger.error(f"Error renewing lock {self.lock_id}: {e}") + + def start(self): + """Start the background renewal thread.""" + self._stop_event.clear() + self._thread = threading.Thread( + target=self._renew_loop, daemon=True, + name=f"lock-renew-{self.task_name}-{self.id}" + ) + self._thread.start() + return self + + def stop(self): + """Stop the renewal thread.""" + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + self._thread = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + return False + + def send_websocket_update(group_name, event_type, data, collect_garbage=False): """ Standardized function to send WebSocket updates with proper memory management. @@ -397,6 +466,83 @@ def validate_flexible_url(value): # If it doesn't match our flexible patterns, raise the original error raise ValidationError("Enter a valid URL.") +def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details): + try: + from apps.connect.utils import trigger_event + from apps.channels.models import Channel, Stream + from core.models import StreamProfile + from core.utils import RedisClient + + payload = dict(details) + + channel_obj = None + if channel_id: + try: + channel_obj = Channel.objects.get(uuid=channel_id) + payload["channel_name"] = channel_obj.name + except Exception: + payload["channel_name"] = channel_name or None + else: + payload["channel_name"] = channel_name or None + + # Resolve current stream info + stream_id = details.get("stream_id") + stream_obj = None + if not stream_id and channel_obj: + try: + redis = RedisClient.get_client() + sid = redis.get(f"channel_stream:{channel_obj.id}") + if sid: + stream_id = int(sid) + except Exception: + stream_id = None + + if stream_id: + try: + stream_obj = Stream.objects.get(id=stream_id) + except Exception: + stream_obj = None + + # Populate stream details + payload["stream_name"] = getattr(stream_obj, "name", None) + payload["stream_url"] = getattr(stream_obj, "url", None) + + # Channel URL: use stream URL as best-effort + payload["channel_url"] = payload.get("stream_url") + + # Provider name from M3U account + provider_name = None + try: + if stream_obj and stream_obj.m3u_account: + provider_name = stream_obj.m3u_account.name + except Exception: + provider_name = None + payload["provider_name"] = provider_name + + # Profile used + profile_used = None + try: + if stream_id: + redis = RedisClient.get_client() + pid = redis.get(f"stream_profile:{stream_id}") + if pid: + profile = StreamProfile.objects.filter(id=int(pid)).first() + profile_used = profile.name if profile else None + except Exception: + profile_used = None + + payload["profile_used"] = profile_used + + # remove empty keys + for k in list(payload.keys()): + if not payload[k]: + del payload[k] + + trigger_event(event_type, payload) + + except Exception as e: + # Don't fail main path if connect dispatch fails + pass def log_system_event(event_type, channel_id=None, channel_name=None, **details): """ @@ -423,6 +569,9 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details): details=details ) + # Trigger connect integrations for specific events + dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details) + # Get max events from settings (default 100) try: from .models import CoreSettings @@ -517,4 +666,3 @@ def send_notification_dismissed(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 c9bdebfa..c8350895 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -34,6 +34,7 @@ INSTALLED_APPS = [ "apps.proxy.apps.ProxyConfig", "apps.proxy.ts_proxy", "apps.vod.apps.VODConfig", + "apps.connect.apps.ConnectConfig", "core", "daphne", "drf_spectacular", @@ -167,6 +168,7 @@ REST_FRAMEWORK = { ], "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", + "apps.accounts.authentication.ApiKeyAuthentication", ], "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], } @@ -411,3 +413,18 @@ LOGGING = { "level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO' }, } + +# Connect script execution safety settings +# Allowed base directories for custom scripts; real paths must be inside +_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/scripts") +CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p] + +# Max execution time (seconds) for scripts +CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10")) + +# Truncate stdout/stderr to this many characters to avoid large outputs +CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536")) + +# Require executable bit and disallow world-writable files +CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True +CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e212bd37..8d086500 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -119,6 +119,11 @@ services: # Process Priority Configuration (Optional) #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features: + #- USE_LEGACY_NUMPY=true + # Django Configuration - DJANGO_SETTINGS_MODULE=dispatcharr.settings - PYTHONUNBUFFERED=1 diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh index 81cece86..e5c2f340 100644 --- a/docker/entrypoint.celery.sh +++ b/docker/entrypoint.celery.sh @@ -4,15 +4,32 @@ set -e cd /app source /dispatcharrpy/bin/activate +# Function to echo with timestamp +echo_with_timestamp() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" +} + # Wait for Django secret key echo 'Waiting for Django secret key...' while [ ! -f /data/jwt ]; do sleep 1; done export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)" +# --- 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" || [ $? -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" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + # 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 '\[ \]'; do - echo 'Migrations not ready yet, waiting...' + echo_with_timestamp 'Migrations not ready yet, waiting...' sleep 2 done diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8ea47318..b79af952 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -163,20 +163,9 @@ 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 using Python (no pg_isready needed) + # Wait for external PostgreSQL to be ready using pg_isready (checks actual protocol readiness) echo_with_timestamp "Waiting for external PostgreSQL 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(('${POSTGRES_HOST}', ${POSTGRES_PORT})) - s.close() - sys.exit(0) -except Exception: - sys.exit(1) -" 2>/dev/null; do + until $PG_BINDIR/pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -q >/dev/null 2>&1; do echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." sleep 1 done @@ -191,12 +180,9 @@ 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 +import socket, sys try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(2) - s.connect(('${REDIS_HOST}', ${REDIS_PORT})) + s = socket.create_connection(('${REDIS_HOST}', ${REDIS_PORT}), timeout=2) s.close() sys.exit(0) except Exception: diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index 0c317017..b4524bbc 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -10,6 +10,7 @@ DATA_DIRS=( "/data/epgs" "/data/plugins" "/data/models" + "/data/scripts" ) APP_DIRS=( @@ -77,4 +78,4 @@ if [ "$(id -u)" = "0" ]; then fi chmod +x /data -fi \ No newline at end of file +fi diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a349a3d..9b16acc8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -62,9 +62,9 @@ } }, "node_modules/@acemir/cssom": { - "version": "0.9.29", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz", - "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, "license": "MIT" }, @@ -76,23 +76,23 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", - "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -100,7 +100,7 @@ "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.4" + "lru-cache": "^11.2.6" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -111,12 +111,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -125,13 +125,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -150,13 +150,13 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -181,12 +181,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -196,40 +196,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -237,9 +237,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -250,9 +250,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "dev": true, "funding": [ { @@ -266,13 +266,13 @@ ], "license": "MIT-0", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "dev": true, "funding": [ { @@ -286,17 +286,17 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "dev": true, "funding": [ { @@ -310,21 +310,21 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" }, "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", "dev": true, "funding": [ { @@ -338,16 +338,16 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "@csstools/css-tokenizer": "^4.0.0" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.21.tgz", - "integrity": "sha512-plP8N8zKfEZ26figX4Nvajx8DuzfuRpLTqglQ5d0chfnt35Qt3X+m6ASZ+rG0D0kxe/upDVNwSIVJP5n4FuNfw==", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", "dev": true, "funding": [ { @@ -359,15 +359,12 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } + "license": "MIT-0" }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", "dev": true, "funding": [ { @@ -381,7 +378,7 @@ ], "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20.19.0" } }, "node_modules/@dnd-kit/accessibility": { @@ -598,9 +595,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -615,9 +612,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -632,9 +629,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -649,9 +646,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -666,9 +663,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -683,9 +680,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -700,9 +697,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -717,9 +714,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -734,9 +731,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -751,9 +748,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -768,9 +765,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -785,9 +782,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -802,9 +799,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -819,9 +816,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -836,9 +833,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -853,9 +850,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -870,9 +867,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -887,9 +884,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -904,9 +901,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -921,9 +918,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -938,9 +935,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -955,9 +952,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -972,9 +969,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -989,9 +986,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1006,9 +1003,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1023,9 +1020,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1040,9 +1037,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1160,9 +1157,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1196,22 +1193,40 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, @@ -1231,12 +1246,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -1348,12 +1363,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", - "license": "Apache-2.0" - }, "node_modules/@mantine/charts": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-8.0.2.tgz", @@ -1475,16 +1484,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", - "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", "cpu": [ "arm" ], @@ -1496,9 +1505,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", - "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", "cpu": [ "arm64" ], @@ -1510,9 +1519,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", - "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", "cpu": [ "arm64" ], @@ -1524,9 +1533,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", - "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", "cpu": [ "x64" ], @@ -1538,9 +1547,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", - "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", "cpu": [ "arm64" ], @@ -1552,9 +1561,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", - "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", "cpu": [ "x64" ], @@ -1566,9 +1575,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", - "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", "cpu": [ "arm" ], @@ -1580,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", - "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", "cpu": [ "arm" ], @@ -1594,9 +1603,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", - "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", "cpu": [ "arm64" ], @@ -1608,9 +1617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", - "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", "cpu": [ "arm64" ], @@ -1622,9 +1631,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", - "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", "cpu": [ "loong64" ], @@ -1636,9 +1659,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", - "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", "cpu": [ "ppc64" ], @@ -1650,9 +1687,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", - "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", "cpu": [ "riscv64" ], @@ -1664,9 +1701,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", - "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", "cpu": [ "riscv64" ], @@ -1678,9 +1715,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", - "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", "cpu": [ "s390x" ], @@ -1692,9 +1729,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", - "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", "cpu": [ "x64" ], @@ -1706,9 +1743,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", - "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", "cpu": [ "x64" ], @@ -1719,10 +1756,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", - "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", "cpu": [ "arm64" ], @@ -1734,9 +1785,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", - "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", "cpu": [ "arm64" ], @@ -1748,9 +1799,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", - "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", "cpu": [ "ia32" ], @@ -1762,9 +1813,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", - "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", "cpu": [ "x64" ], @@ -1776,9 +1827,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", - "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", "cpu": [ "x64" ], @@ -1803,10 +1854,197 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", + "integrity": "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", + "integrity": "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", + "integrity": "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", + "integrity": "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", + "integrity": "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", + "integrity": "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", + "integrity": "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", + "integrity": "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", + "integrity": "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", + "integrity": "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@swc/wasm": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.7.tgz", - "integrity": "sha512-m1Cslgkp7gFIUB2ZiIUHMoUskwxOAi9uaf27inoKb7Oc8MkMjt+eNTeSyeGckkwRtMQiybKYTGGnA5imxSsedQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.11.tgz", + "integrity": "sha512-230rdYZf8ux3nIwISOQNCFrxzxpL/UFY4Khv/3UsvpEdo709j/+Tg80yXWW3DXETeZNPBV72QpvEBhXsl7Lc9g==", "dev": true, "license": "Apache-2.0" }, @@ -1891,9 +2129,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", - "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", "dependencies": { @@ -1993,9 +2231,9 @@ } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -2041,9 +2279,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2061,9 +2299,9 @@ } }, "node_modules/@videojs/http-streaming": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.2.tgz", - "integrity": "sha512-VBQ3W4wnKnVKb/limLdtSD2rAd5cmHN70xoMf4OmuDd0t2kfJX04G+sfw6u2j8oOm2BXYM9E1f4acHruqKnM1g==", + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.17.4.tgz", + "integrity": "sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.12.5", @@ -2109,14 +2347,14 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", - "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", + "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.47", - "@swc/core": "^1.13.5" + "@rolldown/pluginutils": "1.0.0-rc.2", + "@swc/core": "^1.15.11" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -2125,6 +2363,45 @@ "vite": "^4 || ^5 || ^6 || ^7" } }, + "node_modules/@vitejs/plugin-react-swc/node_modules/@swc/core": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", + "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.11", + "@swc/core-darwin-x64": "1.15.11", + "@swc/core-linux-arm-gnueabihf": "1.15.11", + "@swc/core-linux-arm64-gnu": "1.15.11", + "@swc/core-linux-arm64-musl": "1.15.11", + "@swc/core-linux-x64-gnu": "1.15.11", + "@swc/core-linux-x64-musl": "1.15.11", + "@swc/core-win32-arm64-msvc": "1.15.11", + "@swc/core-win32-ia32-msvc": "1.15.11", + "@swc/core-win32-x64-msvc": "1.15.11" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -2250,9 +2527,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2295,9 +2572,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2312,36 +2589,23 @@ } }, "node_modules/allotment": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.4.tgz", - "integrity": "sha512-LMM5Xe5nLePFOLAlW/5k3ARqznYGUyNekV4xJrfDKn1jimW3nlZE6hT/Tu0T8s0VgAkr9s2P7+uM0WvJKn5DAw==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/allotment/-/allotment-1.20.5.tgz", + "integrity": "sha512-7i4NT7ieXEyAd5lBrXmE7WHz/e7hRuo97+j+TwrPE85ha6kyFURoc76nom0dWSZ1pTKVEAMJy/+f3/Isfu/41A==", "license": "MIT", "dependencies": { "classnames": "^2.3.0", "eventemitter3": "^5.0.0", + "fast-deep-equal": "^3.1.3", "lodash.clamp": "^4.0.0", "lodash.debounce": "^4.0.0", - "lodash.isequal": "^4.5.0", - "use-resize-observer": "^9.0.0" + "usehooks-ts": "^3.1.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/allotment/node_modules/use-resize-observer": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", - "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==", - "license": "MIT", - "dependencies": { - "@juggle/resize-observer": "^3.3.1" - }, - "peerDependencies": { - "react": "16.8.0 - 18", - "react-dom": "16.8.0 - 18" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2420,11 +2684,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "20 || >=22" + } }, "node_modules/bidi-js": { "version": "1.0.3", @@ -2437,14 +2704,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/cac": { @@ -2501,9 +2770,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -2545,13 +2814,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2633,15 +2895,16 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", - "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0" + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" }, "engines": { "node": ">=20" @@ -2684,9 +2947,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -2775,19 +3038,29 @@ } }, "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" }, "engines": { "node": ">=20" } }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -2915,9 +3188,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2928,32 +3201,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escape-string-regexp": { @@ -2969,9 +3242,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { @@ -2981,7 +3254,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3100,9 +3373,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3156,9 +3429,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/expect-type": { @@ -3393,17 +3666,23 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/http-proxy-agent": { @@ -3434,19 +3713,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3586,18 +3852,19 @@ } }, "node_modules/jsdom": { - "version": "27.3.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", - "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^4.0.0", + "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", @@ -3607,7 +3874,6 @@ "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.0", - "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^15.1.0", "ws": "^8.18.3", @@ -3737,13 +4003,6 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3771,9 +4030,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3853,16 +4112,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mpd-parser": { @@ -4164,9 +4426,9 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -4207,13 +4469,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4234,6 +4489,12 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/property-expr": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", @@ -4251,24 +4512,24 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-draggable": { @@ -4303,9 +4564,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.70.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", - "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4319,9 +4580,10 @@ } }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, "license": "MIT" }, "node_modules/react-lifecycles-compat": { @@ -4404,9 +4666,9 @@ } }, "node_modules/react-router": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", - "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4426,12 +4688,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", - "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", "license": "MIT", "dependencies": { - "react-router": "7.12.0" + "react-router": "7.13.0" }, "engines": { "node": ">=20.0.0" @@ -4663,9 +4925,9 @@ } }, "node_modules/rollup": { - "version": "4.53.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", - "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", "dev": true, "license": "MIT", "dependencies": { @@ -4679,38 +4941,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.5", - "@rollup/rollup-android-arm64": "4.53.5", - "@rollup/rollup-darwin-arm64": "4.53.5", - "@rollup/rollup-darwin-x64": "4.53.5", - "@rollup/rollup-freebsd-arm64": "4.53.5", - "@rollup/rollup-freebsd-x64": "4.53.5", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", - "@rollup/rollup-linux-arm-musleabihf": "4.53.5", - "@rollup/rollup-linux-arm64-gnu": "4.53.5", - "@rollup/rollup-linux-arm64-musl": "4.53.5", - "@rollup/rollup-linux-loong64-gnu": "4.53.5", - "@rollup/rollup-linux-ppc64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-gnu": "4.53.5", - "@rollup/rollup-linux-riscv64-musl": "4.53.5", - "@rollup/rollup-linux-s390x-gnu": "4.53.5", - "@rollup/rollup-linux-x64-gnu": "4.53.5", - "@rollup/rollup-linux-x64-musl": "4.53.5", - "@rollup/rollup-openharmony-arm64": "4.53.5", - "@rollup/rollup-win32-arm64-msvc": "4.53.5", - "@rollup/rollup-win32-ia32-msvc": "4.53.5", - "@rollup/rollup-win32-x64-gnu": "4.53.5", - "@rollup/rollup-win32-x64-msvc": "4.53.5", + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", "fsevents": "~2.3.2" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4884,9 +5142,9 @@ "license": "MIT" }, "node_modules/tabbable": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", - "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tiny-case": { @@ -4963,22 +5221,22 @@ } }, "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.19" + "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", "dev": true, "license": "MIT" }, @@ -5143,6 +5401,21 @@ } } }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -5166,13 +5439,13 @@ } }, "node_modules/video.js": { - "version": "8.23.4", - "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", - "integrity": "sha512-qI0VTlYmKzEqRsz1Nppdfcaww4RSxZAq77z2oNSl3cNg2h6do5C8Ffl0KqWQ1OpD8desWXsCrde7tKJ9gGTEyQ==", + "version": "8.23.7", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.7.tgz", + "integrity": "sha512-cG4HOygYt+Z8j6Sf5DuK6OgEOoM+g9oGP6vpqoZRaD13aHE4PMITbyjJUXZcIQbgB0wJEadBRaVm5lJIzo2jAA==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.12.5", - "@videojs/http-streaming": "^3.17.2", + "@babel/runtime": "^7.28.4", + "@videojs/http-streaming": "^3.17.3", "@videojs/vhs-utils": "^4.1.1", "@videojs/xhr": "2.7.0", "aes-decrypter": "^4.0.2", @@ -5217,9 +5490,9 @@ } }, "node_modules/vite": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", - "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { @@ -5401,9 +5674,9 @@ } }, "node_modules/webidl-conversions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", - "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -5415,19 +5688,6 @@ "resolved": "git+ssh://git@github.com/xqq/webworkify-webpack.git#24d1e719b4a6cac37a518b2bb10fe124527ef4ef", "license": "MIT" }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -5496,9 +5756,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "dev": true, "license": "MIT", "engines": { @@ -5590,9 +5850,9 @@ } }, "node_modules/zustand": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", - "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/frontend/package.json b/frontend/package.json index 7b2d5927..16197c31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -70,6 +70,7 @@ "react-dom": "19.1.0" }, "overrides": { - "js-yaml": "^4.1.1" + "js-yaml": "^4.1.1", + "minimatch": "^10.2.1" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3869740e..7950e8ae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,8 @@ import Stats from './pages/Stats'; import DVR from './pages/DVR'; import Settings from './pages/Settings'; import PluginsPage from './pages/Plugins'; +import ConnectPage from './pages/Connect'; +import ConnectLogsPage from './pages/ConnectLogs'; import Users from './pages/Users'; import LogosPage from './pages/Logos'; import VODsPage from './pages/VODs'; @@ -61,7 +63,7 @@ const App = () => { async function checkSuperuser() { try { const response = await API.fetchSuperUser(); - if (!response.superuser_exists) { + if (response && response.superuser_exists === false) { setSuperuserExists(false); } } catch (error) { @@ -152,6 +154,11 @@ const App = () => { } /> } /> } /> + } /> + } + /> } /> } /> } /> diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 2d6cc0d9..c18a7635 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -424,7 +424,7 @@ export const WebsocketProvider = ({ children }) => { // Refresh channels data and logos try { await API.requeryChannels(); - await useChannelsStore.getState().fetchChannels(); + await useChannelsStore.getState().fetchChannelIds(); // Get updated channel data and extract logo IDs to load const channels = useChannelsStore.getState().channels; @@ -489,7 +489,7 @@ export const WebsocketProvider = ({ children }) => { // Refresh channels data try { await API.requeryChannels(); - await useChannelsStore.getState().fetchChannels(); + await useChannelsStore.getState().fetchChannelIds(); } catch (e) { console.warn( 'Failed to refresh channels after name setting:', @@ -704,7 +704,7 @@ export const WebsocketProvider = ({ children }) => { try { await API.requeryChannels(); await API.requeryStreams(); - await useChannelsStore.getState().fetchChannels(); + await useChannelsStore.getState().fetchChannelIds(); } catch (error) { console.error( 'Error refreshing channels/streams after rehash:', @@ -767,7 +767,7 @@ export const WebsocketProvider = ({ children }) => { try { await API.requeryChannels(); await API.requeryStreams(); - await useChannelsStore.getState().fetchChannels(); + useChannelsStore.getState().fetchChannelIds(); await fetchChannelProfiles(); console.log('Channels refreshed after bulk creation'); } catch (error) { diff --git a/frontend/src/api.js b/frontend/src/api.js index 2b4c192d..7049b7b8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -12,6 +12,8 @@ import { notifications } from '@mantine/notifications'; import useChannelsTableStore from './store/channelsTable'; import useStreamsTableStore from './store/streamsTable'; import useUsersStore from './store/users'; +import useConnectStore from './store/connect'; +import Limiter from './utils'; // If needed, you can set a base host or keep it empty if relative requests const host = import.meta.env.DEV @@ -104,6 +106,41 @@ export default class API { return await useAuthStore.getState().getToken(); } + /** + * Fetch all pages for a paginated endpoint when you already know totalCount. + * Builds page calls from totalCount and pageSize and aggregates all results. + * - endpoint: path like "/api/channels/channels/" + * - params: URLSearchParams for filters (will not be mutated) + * - totalCount: total number of matching items + * - pageSize: items per page + * Returns a flat array of results. Supports both array and {results, next} responses. + */ + static async fetchAllByCount(endpoint, params, totalCount, pageSize = 200) { + const total = Number(totalCount) || 0; + const size = Number(pageSize) || 200; + const totalPages = Math.max(1, Math.ceil(total / size)); + + const requests = []; + for (let page = 1; page <= totalPages; page++) { + const q = new URLSearchParams(params || new URLSearchParams()); + q.set('page', String(page)); + q.set('page_size', String(size)); + const url = `${host}${endpoint}?${q.toString()}`; + requests.push(request(url)); + } + + const responses = await Promise.all(requests); + const all = []; + for (const data of responses) { + if (Array.isArray(data)) { + all.push(...data); + } else if (Array.isArray(data?.results)) { + all.push(...data.results); + } + } + return all; + } + static async fetchSuperUser() { try { return await request(`${host}/api/accounts/initialize-superuser/`, { @@ -178,14 +215,62 @@ export default class API { static async getChannels() { try { - const response = await request(`${host}/api/channels/channels/`); + // Paginate through channels to avoid heavy single response + const pageSize = 200; + const allChannels = []; - return response; + // Get first page to get total results count + const data = await request( + `${host}/api/channels/channels/?page=1&page_size=${pageSize}` + ); + + // Backward compatibility: if endpoint returns an array (legacy), just return it + if (Array.isArray(data)) { + return data; + } + + allChannels.concat(Array.isArray(data?.results) ? data.results : []); + + const totalPages = Math.max(1, Math.ceil(data.count / pageSize)) - 1; + const apiCalls = []; + for (let page = 2; page <= totalPages; page++) { + apiCalls.push( + new Promise(async (resolve) => { + const response = await request( + `${host}/api/channels/channels/?page=${page}&page_size=${pageSize}` + ); + + return resolve( + Array.isArray(response?.results) ? response.results : [] + ); + }) + ); + } + + const allResults = await Limiter.all(5, apiCalls); + + return allResults; } catch (e) { errorNotification('Failed to retrieve channels', e); } } + /** + * Retrieve a lightweight summary of channels (id, name, logo_id, + * channel_number, uuid, epg_data_id, channel_group_id). + * Designed for the TV Guide where full channel data is not needed. + */ + static async getChannelsSummary(params = new URLSearchParams()) { + try { + const url = `${host}/api/channels/channels/summary/?${params.toString()}`; + const data = await request(url); + return Array.isArray(data) ? data : []; + } catch (e) { + errorNotification('Failed to retrieve channel summary', e); + return []; + } + } + static async queryChannels(params) { try { API.lastQueryParams = params; @@ -227,6 +312,44 @@ export default class API { } } + /** + * Retrieve channels matching the provided query params, paging until complete. + * Does NOT touch any table/store state; returns a plain array. + */ + static async getChannelsForParams(params) { + try { + const pageSize = 200; + const query = new URLSearchParams(params); + let page = 1; + let all = []; + + while (true) { + query.set('page', String(page)); + query.set('page_size', String(pageSize)); + const url = `${host}/api/channels/channels/?${query.toString()}`; + const data = await request(url); + + if (Array.isArray(data)) { + // Legacy array response + all = data; + break; + } + + const results = Array.isArray(data?.results) ? data.results : []; + all = all.concat(results); + + const hasMore = Boolean(data?.next); + if (!hasMore || results.length === 0) break; + page += 1; + } + + return all; + } catch (e) { + errorNotification('Failed to retrieve channels for query', e); + throw e; + } + } + static async requeryChannels() { try { const [response, ids] = await Promise.all([ @@ -276,7 +399,7 @@ export default class API { } } - static async getAllChannelIds(params) { + static async getAllChannelIds(params = new URLSearchParams()) { try { const response = await request( `${host}/api/channels/channels/ids/?${params.toString()}` @@ -565,6 +688,43 @@ export default class API { } } + // Server-side regex rename of channel names for selected IDs + static async bulkRegexRenameChannels( + channelIds, + find, + replace = '', + flags = 'g' + ) { + try { + const response = await request( + `${host}/api/channels/channels/edit/bulk-regex/`, + { + method: 'POST', + body: { + channel_ids: channelIds, + find, + replace, + flags, + }, + } + ); + + // Optional success notification + if (response?.success) { + notifications.show({ + title: 'Channel Names Updated', + message: `Renamed ${response.updated_count} channel(s) via regex`, + color: 'green', + autoClose: 4000, + }); + } + + return response; + } catch (e) { + errorNotification('Failed to apply regex renames', e); + } + } + static async reorderChannel(channelId, insertAfterId) { try { const response = await request( @@ -1095,9 +1255,6 @@ export default class API { }); usePlaylistsStore.getState().removePlaylists([id]); - // @TODO: MIGHT need to optimize this later if someone has thousands of channels - // but I'm feeling laze right now - // useChannelsStore.getState().fetchChannels(); } catch (e) { errorNotification(`Failed to delete playlist ${id}`, e); } @@ -1174,11 +1331,11 @@ export default class API { } } - static async getCurrentPrograms(channelIds = null) { + static async getCurrentPrograms(channelUUIDs = null) { try { const response = await request(`${host}/api/epg/current-programs/`, { method: 'POST', - body: { channel_ids: channelIds }, + body: { channel_uuids: channelUUIDs }, }); return response; @@ -2668,8 +2825,6 @@ export default class API { color: 'blue', }); - // First fetch the complete channel data - await useChannelsStore.getState().fetchChannels(); // Then refresh the current table view this.requeryChannels(); } @@ -2720,6 +2875,62 @@ export default class API { } } + static async generateApiKey({ user_id = null, name = '' } = {}) { + try { + const body = {}; + if (user_id) body.user_id = user_id; + if (name) body.name = name; + + const response = await request( + `${host}/api/accounts/api-keys/generate/`, + { + method: 'POST', + body, + } + ); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to generate API key', e); + } + } + + static async revokeApiKey({ user_id = null } = {}) { + try { + const body = {}; + if (user_id) { + body.user_id = user_id; + } + + const response = await request(`${host}/api/accounts/api-keys/revoke/`, { + method: 'POST', + body, + }); + + // If the backend returned an updated user, refresh the users store + try { + if (response && response.user) { + useUsersStore.getState().updateUser(response.user); + } + } catch (e) { + // ignore store update errors + } + + return response; + } catch (e) { + errorNotification('Failed to revoke API key', e); + } + } + static async updateUser(id, body) { try { const response = await request(`${host}/api/accounts/users/${id}/`, { @@ -2783,6 +2994,23 @@ export default class API { } } + static async getChannelsByUUIDs(uuids) { + try { + // Use POST for large lists + const response = await request( + `${host}/api/channels/channels/by-uuids/`, + { + method: 'POST', + body: { uuids }, + } + ); + return response; + } catch (e) { + errorNotification('Failed to retrieve channels by UUIDs', e); + throw e; + } + } + // VOD Methods static async getMovies(params = new URLSearchParams()) { try { @@ -3046,4 +3274,124 @@ export default class API { errorNotification('Failed to dismiss all notifications', e); } } + + static async getConnectIntegrations() { + try { + return await request(`${host}/api/connect/integrations/`); + } catch (e) { + errorNotification('Failed to fetch connect integrations', e); + } + } + + static async createConnectIntegration(values) { + try { + const response = await request(`${host}/api/connect/integrations/`, { + method: 'POST', + body: values, + }); + + useConnectStore.getState().addIntegration(response); + + return response; + } catch (e) { + errorNotification('Failed to create integration', e); + } + } + + static async updateConnectIntegration(id, values) { + try { + const response = await request( + `${host}/api/connect/integrations/${id}/`, + { + method: 'PUT', + body: values, + } + ); + + if (response.id) { + useConnectStore.getState().updateIntegration(response); + } + + return response; + } catch (e) { + errorNotification('Failed to update integration', e); + } + } + + static async deleteConnectIntegration(id) { + try { + await request(`${host}/api/connect/integrations/${id}/`, { + method: 'DELETE', + }); + + useConnectStore.getState().removeIntegration(id); + + return true; + } catch (e) { + errorNotification('Failed to delete integration', e); + throw e; + } + } + + static async createConnectSubscription(values) { + try { + await request(`${host}/api/connect/subscriptions/`, { + method: 'POST', + body: values, + }); + + return true; + } catch (e) { + errorNotification('Failed to create subscription', e); + } + } + + static async listConnectSubscriptions(integrationId) { + try { + return await request( + `${host}/api/connect/integrations/${integrationId}/subscriptions/` + ); + } catch (e) { + errorNotification('Failed to fetch subscriptions', e); + } + } + + static async setConnectSubscriptions(integrationId, subscriptions) { + // subscriptions: [{ event, enabled, payload_template }] + console.log(subscriptions); + try { + const response = await request( + `${host}/api/connect/integrations/${integrationId}/subscriptions/set/`, + { + method: 'PUT', + body: subscriptions, + } + ); + + useConnectStore + .getState() + .updateIntegrationSubscriptions(integrationId, response); + + return true; + } catch (e) { + errorNotification('Failed to set subscriptions', e); + throw e; + } + } + + static async getConnectLogs(params = {}) { + try { + const search = new URLSearchParams(); + if (params.page) search.set('page', params.page); + if (params.page_size) search.set('page_size', params.page_size); + if (params.type) search.set('type', params.type); + if (params.integration) search.set('integration', params.integration); + + return await request( + `${host}/api/connect/logs/${search.toString() ? `?${search.toString()}` : ''}` + ); + } catch (e) { + errorNotification('Failed to fetch connect logs', e); + } + } } diff --git a/frontend/src/components/GuideRow.jsx b/frontend/src/components/GuideRow.jsx index 28f41563..b1d47c56 100644 --- a/frontend/src/components/GuideRow.jsx +++ b/frontend/src/components/GuideRow.jsx @@ -1,14 +1,20 @@ -import React from 'react'; +import React, { useState } from 'react'; import { CHANNEL_WIDTH, EXPANDED_PROGRAM_HEIGHT, HOUR_WIDTH, + MINUTE_BLOCK_WIDTH, + MINUTE_INCREMENT, PROGRAM_HEIGHT, } from '../pages/guideUtils.js'; import { Box, Flex, Text } from '@mantine/core'; import { Play } from 'lucide-react'; import logo from '../images/logo.png'; +// Buffer in pixels beyond the viewport edges to render programs. +// This prevents pop-in when scrolling horizontally. +const H_BUFFER = 600; + const GuideRow = React.memo(({ index, style, data }) => { const { filteredChannels, @@ -16,13 +22,16 @@ const GuideRow = React.memo(({ index, style, data }) => { expandedProgramId, rowHeights, logos, - hoveredChannelId, - setHoveredChannelId, renderProgram, handleLogoClick, contentWidth, + guideScrollLeftRef, + viewportWidth, + timelineStartMs, } = data; + const [hovered, setHovered] = useState(false); + const channel = filteredChannels[index]; if (!channel) { return null; @@ -35,30 +44,53 @@ const GuideRow = React.memo(({ index, style, data }) => { ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT); + // Horizontal viewport culling — only render programs whose pixel range + // overlaps the visible scroll window (plus a buffer to avoid pop-in). + const scrollLeft = guideScrollLeftRef.current; + const vpLeft = scrollLeft - H_BUFFER; + const vpRight = scrollLeft + viewportWidth + H_BUFFER; + + const visiblePrograms = channelPrograms.filter((program) => { + const leftPx = + ((program.startMs - timelineStartMs) / 60000 / MINUTE_INCREMENT) * + MINUTE_BLOCK_WIDTH; + const durationMin = (program.endMs - program.startMs) / 60000; + const widthPx = (durationMin / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; + // Program is visible if its right edge is past vpLeft AND left edge is before vpRight + return leftPx + widthPx > vpLeft && leftPx < vpRight; + }); + const PlaceholderProgram = () => { + // Only render placeholder blocks that overlap the viewport + const totalPlaceholders = Math.ceil(24 / 2); + const blockWidth = HOUR_WIDTH * 2; return ( <> - {Array.from({ length: Math.ceil(24 / 2) }).map( - (_, placeholderIndex) => ( - - No program data - - ) + {Array.from({ length: totalPlaceholders }).map( + (_, placeholderIndex) => { + const left = placeholderIndex * blockWidth; + if (left + blockWidth < vpLeft || left > vpRight) return null; + return ( + + No program data + + ); + } )} ); @@ -100,10 +132,10 @@ const GuideRow = React.memo(({ index, style, data }) => { h={'100%'} pos="relative" onClick={(event) => handleLogoClick(channel, event)} - onMouseEnter={() => setHoveredChannelId(channel.id)} - onMouseLeave={() => setHoveredChannelId(null)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} > - {hoveredChannelId === channel.id && ( + {hovered && ( { h={'100%'} pl={0} > - {channelPrograms.length > 0 ? ( - channelPrograms.map((program) => + {visiblePrograms.length > 0 ? ( + visiblePrograms.map((program) => renderProgram(program, undefined, channel) ) - ) : ( + ) : channelPrograms.length > 0 ? null : ( )} diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index b9819b4e..371def3b 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -16,7 +16,7 @@ export default function M3URefreshNotification() { const refreshProgress = usePlaylistsStore((s) => s.refreshProgress); const fetchStreams = useStreamsStore((s) => s.fetchStreams); const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups); - const fetchChannels = useChannelsStore((s) => s.fetchChannels); + const fetchChannelIds = useChannelsStore((s) => s.fetchChannelIds); const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists); const fetchEPGData = useEPGsStore((s) => s.fetchEPGData); const fetchCategories = useVODStore((s) => s.fetchCategories); @@ -143,7 +143,7 @@ export default function M3URefreshNotification() { if (data.action == 'parsing') { fetchStreams(); API.requeryChannels(); - fetchChannels(); + fetchChannelIds(); } else if (data.action == 'processing_groups') { fetchStreams(); fetchChannelGroups(); diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 6c997cfe..2b4e35e3 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -5,7 +5,6 @@ import { ListOrdered, Play, Database, - SlidersHorizontal, LayoutGrid, Settings as LucideSettings, Copy, @@ -15,6 +14,12 @@ import { LogOut, User, FileImage, + Webhook, + Logs, + ChevronDown, + ChevronRight, + MonitorCog, + Blocks, } from 'lucide-react'; import { Avatar, @@ -26,9 +31,8 @@ import { UnstyledButton, TextInput, ActionIcon, - Menu, + ScrollArea, } from '@mantine/core'; -import { notifications } from '@mantine/notifications'; import logo from '../images/logo.png'; import useChannelsStore from '../store/channels'; import './sidebar.css'; @@ -70,10 +74,78 @@ const NavLink = ({ item, isActive, collapsed }) => { ); }; +function NavGroup({ label, icon, paths, location, collapsed }) { + const [open, setOpen] = useState(() => + location.pathname.startsWith('/connect') + ); + + const parentActive = paths + .map((path) => path.path) + .includes(location.pathname); + + return ( + + setOpen((o) => !o)} + className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`} + style={{ width: '100%' }} + > + {icon} + {!collapsed && ( + + + {label} + + + + {open ? : } + + + )} + + + {open && ( + + + {paths.map((child) => { + const active = location.pathname === child.path; + return ( + + + + ); + })} + + + )} + + ); +} + const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const location = useLocation(); - const channels = useChannelsStore((s) => s.channels); + const channelIds = useChannelsStore((s) => s.channelIds); const environment = useSettingsStore((s) => s.environment); const appVersion = useSettingsStore((s) => s.version); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -94,7 +166,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { label: 'Channels', icon: , path: '/channels', - badge: `(${Object.keys(channels).length})`, + badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`, }, { label: 'VODs', @@ -111,19 +183,41 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { { label: 'Stats', icon: , path: '/stats' }, { label: 'Plugins', icon: , path: '/plugins' }, { - label: 'Users', - icon: , - path: '/users', + label: 'Integrations', + icon: , + paths: [ + { + label: 'Connections', + icon: , + path: '/connect', + }, + { + label: 'Logs', + icon: , + path: '/connect/logs', + }, + ], }, { - label: 'Logo Manager', - icon: , - path: '/logos', - }, - { - label: 'Settings', - icon: , - path: '/settings', + label: 'System', + icon: , + paths: [ + { + label: 'Users', + icon: , + path: '/users', + }, + { + label: 'Logo Manager', + icon: , + path: '/logos', + }, + { + label: 'Settings', + icon: , + path: '/settings', + }, + ], }, ] : [ @@ -131,7 +225,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { label: 'Channels', icon: , path: '/channels', - badge: `(${Object.keys(channels).length})`, + badge: `(${Array.isArray(channelIds) ? channelIds.length : 0})`, }, { label: 'TV Guide', icon: , path: '/guide' }, { @@ -151,11 +245,6 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }); }; - const onLogout = async () => { - await logout(); - window.location.reload(); - }; - return ( { {/* Navigation Links */} - - {navItems.map((item) => { - const isActive = location.pathname === item.path; + + + {navItems.map((item) => { + if (item.paths) { + return ( + + ); + } - return ( - - ); - })} - + const isActive = location.pathname === item.path; + + return ( + + ); + })} + + {/* Profile Section */} { - // Skip if it's * or contains special characters - if ( - value === '*' || - value.includes('/') || - value.includes('-') || - value.includes(',') - ) { - return null; - } - const num = parseInt(value, 10); - if (isNaN(num) || num < min || num > max) { - return `${name} must be between ${min} and ${max}`; - } - return null; - }; - - const minuteError = validateRange(minute, 0, 59, 'Minute'); - if (minuteError) return { valid: false, error: minuteError }; - - const hourError = validateRange(hour, 0, 23, 'Hour'); - if (hourError) return { valid: false, error: hourError }; - - const dayError = validateRange(dayOfMonth, 1, 31, 'Day'); - if (dayError) return { valid: false, error: dayError }; - - const monthError = validateRange(month, 1, 12, 'Month'); - if (monthError) return { valid: false, error: monthError }; - - const weekdayError = validateRange(dayOfWeek, 0, 6, 'Weekday'); - if (weekdayError) return { valid: false, error: weekdayError }; - - return { valid: true, error: null }; -} - const DAYS_OF_WEEK = [ { value: '0', label: 'Sunday' }, { value: '1', label: 'Monday' }, @@ -262,8 +175,7 @@ export default function BackupManager() { const [scheduleLoading, setScheduleLoading] = useState(false); const [scheduleSaving, setScheduleSaving] = useState(false); const [scheduleChanged, setScheduleChanged] = useState(false); - const [advancedMode, setAdvancedMode] = useState(false); - const [cronError, setCronError] = useState(null); + const [scheduleType, setScheduleType] = useState('interval'); // For 12-hour display mode const [displayTime, setDisplayTime] = useState('3:00'); @@ -373,12 +285,8 @@ export default function BackupManager() { try { const settings = await API.getBackupSchedule(); - // Check if using cron expression (advanced mode) - if (settings.cron_expression) { - setAdvancedMode(true); - } - setSchedule(settings); + setScheduleType(settings.cron_expression ? 'cron' : 'interval'); // Initialize 12-hour display values const { time, period } = to12Hour(settings.time); @@ -398,25 +306,9 @@ export default function BackupManager() { loadSchedule(); }, []); - // Validate cron expression when switching to advanced mode - useEffect(() => { - if (advancedMode && schedule.cron_expression) { - const validation = validateCronExpression(schedule.cron_expression); - setCronError(validation.valid ? null : validation.error); - } else { - setCronError(null); - } - }, [advancedMode, schedule.cron_expression]); - const handleScheduleChange = (field, value) => { setSchedule((prev) => ({ ...prev, [field]: value })); setScheduleChanged(true); - - // Validate cron expression if in advanced mode - if (field === 'cron_expression' && advancedMode) { - const validation = validateCronExpression(value); - setCronError(validation.valid ? null : validation.error); - } }; // Handle time changes in 12-hour mode @@ -442,9 +334,11 @@ export default function BackupManager() { const handleSaveSchedule = async () => { setScheduleSaving(true); try { - const scheduleToSave = advancedMode - ? schedule - : { ...schedule, cron_expression: '' }; + // Clear cron_expression if not in cron mode + const scheduleToSave = + scheduleType === 'cron' + ? schedule + : { ...schedule, cron_expression: '' }; const updated = await API.updateBackupSchedule(scheduleToSave); setSchedule(updated); @@ -603,207 +497,161 @@ export default function BackupManager() { /> - - - Advanced (Cron Expression) - - setAdvancedMode(e.currentTarget.checked)} - label={advancedMode ? 'Enabled' : 'Disabled'} - disabled={!schedule.enabled} - size="sm" - /> - + { + setScheduleType(type); + if (type !== 'cron') { + handleScheduleChange('cron_expression', ''); + } + }} + cronValue={schedule.cron_expression} + onCronChange={(expr) => handleScheduleChange('cron_expression', expr)} + disabled={!schedule.enabled} + switchToCronLabel="Use custom cron schedule" + switchToIntervalLabel="Use simple schedule" + > + {/* Simple mode: frequency / time / day selectors */} + + + + handleScheduleChange('day_of_week', parseInt(value, 10)) + } + data={DAYS_OF_WEEK} + disabled={!schedule.enabled} + /> + )} + {is12Hour ? ( + <> + { + const hour = displayTime + ? displayTime.split(':')[0] + : '12'; + handleTimeChange12h(`${hour}:${value}`, null); + }} + data={Array.from({ length: 60 }, (_, i) => ({ + value: String(i).padStart(2, '0'), + label: String(i).padStart(2, '0'), + }))} + disabled={!schedule.enabled} + searchable + /> + { + const minute = schedule.time + ? schedule.time.split(':')[1] + : '00'; + handleTimeChange24h(`${value}:${minute}`); + }} + data={Array.from({ length: 24 }, (_, i) => ({ + value: String(i).padStart(2, '0'), + label: String(i).padStart(2, '0'), + }))} + disabled={!schedule.enabled} + searchable + /> + - handleScheduleChange('frequency', value) - } - data={[ - { value: 'daily', label: 'Daily' }, - { value: 'weekly', label: 'Weekly' }, - ]} - disabled={!schedule.enabled} - /> - {schedule.frequency === 'weekly' && ( - { - const minute = displayTime - ? displayTime.split(':')[1] - : '00'; - handleTimeChange12h(`${value}:${minute}`, null); - }} - data={Array.from({ length: 12 }, (_, i) => ({ - value: String(i + 1), - label: String(i + 1), - }))} - disabled={!schedule.enabled} - searchable - /> - handleTimeChange12h(null, value)} - data={[ - { value: 'AM', label: 'AM' }, - { value: 'PM', label: 'PM' }, - ]} - disabled={!schedule.enabled} - /> - - ) : ( - <> - { - const hour = schedule.time - ? schedule.time.split(':')[0] - : '00'; - handleTimeChange24h(`${hour}:${value}`); - }} - data={Array.from({ length: 60 }, (_, i) => ({ - value: String(i).padStart(2, '0'), - label: String(i).padStart(2, '0'), - }))} - disabled={!schedule.enabled} - searchable - /> - - )} - - - - handleScheduleChange('retention_count', value || 0) - } - min={0} - disabled={!schedule.enabled} - /> - - - - )} + + + handleScheduleChange('retention_count', value || 0) + } + min={0} + disabled={!schedule.enabled} + /> + + {/* Timezone info - only show in simple mode */} - {!advancedMode && schedule.enabled && schedule.time && ( + {scheduleType !== 'cron' && schedule.enabled && schedule.time && ( System Timezone: {userTimezone} • Backup will run at{' '} {schedule.time} {userTimezone} diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index e16cec34..81d1148b 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -14,9 +14,11 @@ import { Switch, Text, UnstyledButton, + Badge, } from '@mantine/core'; import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'; import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js'; +import { SUBSCRIPTION_EVENTS } from '../../constants.js'; const PluginFieldList = ({ plugin, settings, updateField }) => { return plugin.fields.map((f) => ( @@ -35,30 +37,45 @@ const PluginActionList = ({ runningActionId, handlePluginRun, }) => { - return plugin.actions.map((action) => ( - -
- {action.label} - {action.description && ( - - {action.description} - - )} -
- -
- )); + return plugin.actions.map((action) => { + const events = Array.isArray(action?.events) ? action.events : []; + return ( + +
+ {action.label} + {action.description && ( + + {action.description} + + )} + {events.length > 0 && ( + <> + + Event Triggers + + {events.map((event) => ( + + {SUBSCRIPTION_EVENTS[event] || event} + + ))} + + )} +
+ +
+ ); + }); }; const PluginActionStatus = ({ running, lastResult }) => { diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx index c3bffaee..e1dbf020 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -36,7 +36,12 @@ import { runComSkip, } from './../../utils/cards/RecordingCardUtils.js'; -const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { +const RecordingCard = ({ + recording, + onOpenDetails, + onOpenRecurring, + channel: channelProp = null, +}) => { const channels = useChannelsStore((s) => s.channels); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); @@ -45,7 +50,7 @@ const RecordingCard = ({ recording, onOpenDetails, onOpenRecurring }) => { const { timeFormat: timeformat, dateFormat: dateformat } = useDateTimeFormat(); - const channel = channels?.[recording.channel]; + const channel = channelProp; const customProps = recording.custom_properties || {}; const program = customProps.program || {}; diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index bdbb0b2d..a315c62b 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import useChannelsStore from '../../store/channels'; +import useChannelsTableStore from '../../store/channelsTable.jsx'; import API from '../../api'; import useStreamProfilesStore from '../../store/streamProfiles'; import useEPGsStore from '../../store/epgs'; @@ -245,38 +246,23 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { try { const applyRegex = regexFind.trim().length > 0; - // First, handle standard field updates (name, group, logo, etc.) - if (applyRegex) { - // Build per-channel updates to apply unique names via regex - let flags = 'g'; - let re; - try { - re = new RegExp(regexFind, flags); - } catch (e) { - console.error('Invalid regex:', e); - setIsSubmitting(false); - return; - } - - const channelsMap = useChannelsStore.getState().channels; - const updates = channelIds.map((id) => { - const ch = channelsMap[id]; - const currentName = ch?.name ?? ''; - const newName = currentName.replace(re, regexReplace ?? ''); - const update = { id }; - if (newName !== currentName && newName.trim().length > 0) { - update.name = newName; - } - // Merge base values (group/profile/user_level) if present - Object.assign(update, values); - return update; - }); - - await API.bulkUpdateChannels(updates); - } else if (Object.keys(values).length > 0) { + // First, handle standard field updates (group, logo, profile, etc.) + if (Object.keys(values).length > 0) { await API.updateChannels(channelIds, values); } + // Then, handle name changes via server-side regex to avoid loading all channels client-side + if (applyRegex) { + // Default global replace; case-insensitive could be added later via UI if needed + const flags = 'g'; + await API.bulkRegexRenameChannels( + channelIds, + regexFind, + regexReplace ?? '', + flags + ); + } + // Then, handle EPG assignment if a dummy EPG was selected if (selectedDummyEpgId) { if (selectedDummyEpgId === 'clear') { @@ -318,7 +304,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { // Refresh both the channels table data and the main channels store await Promise.all([ API.requeryChannels(), - useChannelsStore.getState().fetchChannels(), + useChannelsStore.getState().fetchChannelIds(), ]); onClose(); } catch (error) { @@ -1095,7 +1081,17 @@ export default ChannelBatchForm; // Lightweight inline preview component to visualize rename results for a subset const RegexPreview = ({ channelIds, find, replace }) => { - const channelsMap = useChannelsStore((s) => s.channels); + // Use only current page data from the channels table for preview + const pageChannels = useChannelsTableStore((s) => s.channels); + const nameById = useMemo(() => { + const map = {}; + if (Array.isArray(pageChannels)) { + for (const ch of pageChannels) { + if (ch?.id != null) map[ch.id] = ch.name || ''; + } + } + return map; + }, [pageChannels]); const previewItems = useMemo(() => { const items = []; if (!find) return items; @@ -1107,24 +1103,25 @@ const RegexPreview = ({ channelIds, find, replace }) => { console.error('Invalid regex:', error); return [{ before: 'Invalid regex', after: '' }]; } - for (let i = 0; i < Math.min(channelIds.length, 25); i++) { - const id = channelIds[i]; - const before = channelsMap[id]?.name ?? ''; + // Limit preview to items that exist on the current page + const pageOnlyIds = channelIds.filter((id) => nameById[id] !== undefined); + for (let i = 0; i < Math.min(pageOnlyIds.length, 25); i++) { + const id = pageOnlyIds[i]; + const before = nameById[id] ?? ''; const after = before.replace(re, replace ?? ''); if (before !== after) { items.push({ before, after }); } } return items; - }, [channelIds, channelsMap, find, replace]); + }, [channelIds, nameById, find, replace]); if (!find) return null; return ( - Preview (first {Math.min(channelIds.length, 25)} of {channelIds.length}{' '} - selected) + Preview shows matches from the current page only (up to 25). diff --git a/frontend/src/components/forms/Connection.jsx b/frontend/src/components/forms/Connection.jsx new file mode 100644 index 00000000..f4bec8b2 --- /dev/null +++ b/frontend/src/components/forms/Connection.jsx @@ -0,0 +1,382 @@ +import React, { useEffect, useState } from 'react'; +import API from '../../api'; +import { + Button, + Modal, + Select, + Stack, + Flex, + TextInput, + Box, + Checkbox, + Text, + SimpleGrid, + Textarea, + Group, + Tabs, + Accordion, + Alert, +} from '@mantine/core'; +import { isNotEmpty, useForm } from '@mantine/form'; +import { SUBSCRIPTION_EVENTS } from '../../constants'; + +const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map( + ([value, label]) => ({ + value, + label, + }) +); + +const ConnectionForm = ({ connection = null, isOpen, onClose }) => { + const [submitting, setSubmitting] = useState(false); + const [selectedEvents, setSelectedEvents] = useState([]); + const [headers, setHeaders] = useState([]); + const [payloadTemplates, setPayloadTemplates] = useState({}); + const [apiError, setApiError] = useState(''); + + // One-time form + const form = useForm({ + mode: 'controlled', + initialValues: { + name: connection?.name || '', + type: connection?.type || 'webhook', + url: connection?.config?.url || '', + script_path: connection?.config?.path || '', + enabled: connection?.enabled ?? true, + }, + validate: { + name: isNotEmpty('Provide a name'), + type: isNotEmpty('Select a type'), + url: (value, values) => { + if (values.type === 'webhook' && !value.trim()) { + return 'Provide a webhook URL'; + } + return null; + }, + script_path: (value, values) => { + if (values.type === 'script' && !value.trim()) { + return 'Provide a script path'; + } + return null; + }, + }, + }); + + useEffect(() => { + if (connection) { + const values = { + name: connection.name, + type: connection.type, + url: connection.config?.url, + script_path: connection.config?.path, + enabled: connection.enabled, + }; + form.setValues(values); + setSelectedEvents( + connection.subscriptions.reduce((acc, sub) => { + if (sub.enabled) acc.push(sub.event); + return acc; + }, []) + ); + // Initialize headers array from config.headers object + const cfgHeaders = connection.config?.headers || {}; + const hdrs = Object.keys(cfgHeaders).length + ? Object.entries(cfgHeaders).map(([k, v]) => ({ key: k, value: v })) + : [{ key: '', value: '' }]; + setHeaders(hdrs); + + // Initialize payload templates per subscription + const templates = {}; + connection.subscriptions.forEach((sub) => { + if (sub.payload_template) templates[sub.event] = sub.payload_template; + }); + setPayloadTemplates(templates); + } else { + form.reset(); + setSelectedEvents([]); + setHeaders([{ key: '', value: '' }]); + setPayloadTemplates({}); + } + }, [connection]); + + const handleClose = () => { + setApiError(''); + onClose?.(); + }; + + const onSubmit = async (values) => { + console.log(values); + try { + setSubmitting(true); + setApiError(''); + // Build config including optional headers + let config; + if (values.type === 'webhook') { + const hdrs = {}; + headers.forEach((h) => { + if (h.key && h.key.trim()) hdrs[h.key] = h.value; + }); + config = { url: values.url }; + if (Object.keys(hdrs).length) config.headers = hdrs; + } else { + config = { path: values.script_path }; + } + + if (connection) { + await API.updateConnectIntegration(connection.id, { + name: values.name, + type: values.type, + config, + enabled: values.enabled, + }); + } else { + connection = await API.createConnectIntegration({ + name: values.name, + type: values.type, + config, + enabled: values.enabled, + }); + } + + // Build subscription list including optional payload templates + const subs = Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({ + event, + enabled: selectedEvents.includes(event), + payload_template: payloadTemplates[event] || null, + })); + + await API.setConnectSubscriptions(connection.id, subs); + handleClose(); + } catch (error) { + console.error('Failed to create/update connection', error); + // Try to map server-side validation errors to form fields + const body = error?.body; + + if (body && typeof body === 'object') { + const fieldErrors = {}; + if (body.name) { + fieldErrors.name = body.name; + } + if (body.type) { + fieldErrors.type = body.type; + } + if (body.config) { + if (values.type === 'webhook') { + fieldErrors.url = msg; + } else { + fieldErrors.script_path = msg; + } + } + + const nonField = body.non_field_errors || body.detail; + if (Object.keys(fieldErrors).length > 0) { + form.setErrors(fieldErrors); + } + if (nonField) setApiError(nonField); + if (!nonField && Object.keys(fieldErrors).length === 0) { + setApiError(body); + } + } else { + setApiError(error?.message || 'Unknown error'); + } + } finally { + setSubmitting(false); + } + }; + + const toggleEvent = (event) => { + setSelectedEvents((prev) => + prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event] + ); + }; + + if (!isOpen) return null; + + return ( + +
+ + + Settings + Event Triggers + {form.getValues().type === 'webhook' && ( + Payload Templates + )} + + + + + {apiError ? ( + + {apiError} + + ) : null} + +