diff --git a/1.png b/1.png deleted file mode 100644 index 10b11162..00000000 Binary files a/1.png and /dev/null differ diff --git a/2.png b/2.png deleted file mode 100644 index 730a9d07..00000000 Binary files a/2.png and /dev/null differ diff --git a/3.png b/3.png deleted file mode 100644 index 800592bb..00000000 Binary files a/3.png and /dev/null differ diff --git a/4.png b/4.png deleted file mode 100644 index e6d18010..00000000 Binary files a/4.png and /dev/null differ diff --git a/5.png b/5.png deleted file mode 100644 index edd44882..00000000 Binary files a/5.png and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de64782..3c768e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- 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) + +## [0.19.0] - 2026-02-10 + ### Added - Add system notifications and update checks @@ -32,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `useWarningsStore` tests for warning suppression functionality - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) - EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Redis authentication support for modular deployments: Added support for authentication when connecting to external Redis instances using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). REDIS_PASSWORD and REDIS_USER environment variables with URL encoding for special characters. (Closes #937) - Thanks [@CodeBormen](https://github.com/CodeBormen) - Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name. - Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing. - Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown. diff --git a/README.md b/README.md index 9b359e25..1efaecb3 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,87 @@ # 🎬 Dispatcharr β€” Your Ultimate IPTV & Stream Management Companion +

- Dispatcharr Logo + Dispatcharr Logo

--- ## πŸ“– What is Dispatcharr? -Dispatcharr is an **open-source powerhouse** for managing IPTV streams and EPG data with elegance and control.\ +Dispatcharr is an **open-source powerhouse** for managing IPTV streams, EPG data, and VOD content with elegance and control.\ Born from necessity and built with passion, it started as a personal project by **[OkinawaBoss](https://github.com/OkinawaBoss)** and evolved with contributions from legends like **[dekzter](https://github.com/dekzter)**, **[SergeantPanda](https://github.com/SergeantPanda)** and **Bucatini**. -> Think of Dispatcharr as the \*arr family’s IPTV cousin β€” simple, smart, and designed for streamers who want reliability and flexibility. +> Think of Dispatcharr as the \*arr family's IPTV cousin β€” simple, smart, and designed for streamers who want reliability and flexibility. --- -## πŸ§ͺ What’s New in Beta +## 🎯 What Can I Do With Dispatcharr? -Dispatcharr has officially entered **BETA**, bringing powerful new features and improvements across the board: +Dispatcharr empowers you with complete IPTV control. Here are some real-world scenarios: -✨ **Proxy Streaming Engine** β€” Optimize bandwidth, reduce provider connections, and increase stream reliability\ -πŸ“Š **Real-Time Stats Dashboard** β€” Live insights into stream health and client activity\ -🧠 **EPG Auto-Match** β€” Match program data to channels automatically\ -βš™οΈ **Streamlink + FFmpeg Support** β€” Flexible backend options for streaming and recording\ -🎬 **VOD Management** β€” Full Video on Demand support with movies and TV series\ -🧼 **UI & UX Enhancements** β€” Smoother, faster, more responsive interface\ -πŸ› **Output Compatibility** β€” HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more +πŸ’‘ **Consolidate Multiple IPTV Sources**\ +Combine streams from multiple providers into a single interface. Manage, filter, and organize thousands of channels with ease. + +πŸ“Ί **Integrate with Media Centers**\ +Use HDHomeRun emulation to add virtual tuners to **Plex**, **Emby**, or **Jellyfin**. They'll discover Dispatcharr as a live TV source and can record programs directly to their own DVR libraries. + +πŸ“‘ **Create a Personal TV Ecosystem**\ +Merge live TV channels with custom EPG guides. Generate XMLTV schedules or use auto-matching to align channels with existing program data. Export as M3U, Xtream Codes API, or HDHomeRun device. + +πŸ”§ **Transcode & Optimize Streams**\ +Configure output profiles with FFmpeg transcoding to optimize streams for different clients β€” reduce bandwidth, standardize formats, or add audio normalization. + +πŸ” **Centralize VPN Access**\ +Run Dispatcharr through a VPN container (like Gluetun) so all streams route through a single VPN connection. Your clients access geo-blocked content without needing individual VPNs, reducing bandwidth overhead and simplifying network management. + +πŸš€ **Monitor & Manage in Real-Time**\ +Track active streams, client connections, and bandwidth usage with live statistics. Monitor buffering events and stream quality. Automatic failover keeps viewers connected when streams failβ€”seamlessly switching to backup sources without interruption. + +πŸ‘₯ **Share Access Safely**\ +Create multiple user accounts with granular permissions. Share streams via M3U playlists or Xtream Codes API while controlling which users access which channels, profiles, or features. Network-based access restrictions available for additional security. + +πŸ”Œ **Extend with Plugins**\ +Build custom integrations using Dispatcharr's robust plugin system. Automate tasks, connect to external services, or add entirely new workflows. --- ## ✨ Why You'll Love Dispatcharr -βœ… **Full IPTV Control** β€” Import, organize, proxy, and monitor IPTV streams on your own terms\ -βœ… **Smart Playlist Handling** β€” M3U import, filtering, grouping, and failover support\ -βœ… **VOD Content Management** β€” Organize movies and TV series with metadata and streaming\ -βœ… **Reliable EPG Integration** β€” Match and manage TV guide data with ease\ -βœ… **Clean & Responsive Interface** β€” Modern design that gets out of your way\ -βœ… **Fully Self-Hosted** β€” Total control, zero reliance on third-party services +βœ… **Stream Proxy & Relay** β€” Intercept and proxy IPTV streams with real-time client management\ +βœ… **M3U & Xtream Codes** β€” Import, filter, and organize playlists with multiple backend support\ +βœ… **EPG Matching & Generation** β€” Auto-match EPG to channels or generate custom TV guides\ +βœ… **Video on Demand** β€” Stream movies and TV series with rich metadata and IMDB/TMDB integration\ +βœ… **Multi-Format Output** β€” Export as M3U, XMLTV EPG, Xtream Codes API, or HDHomeRun device\ +βœ… **Real-Time Monitoring** β€” Live connection stats, bandwidth tracking, and automatic failover\ +βœ… **Stream Profiles** β€” Configure different stream profiles for various clients and bandwidth requirements\ +βœ… **Flexible Streaming Backends** β€” VLC, FFmpeg, Streamlink, or custom backends for transcoding and streaming\ +βœ… **Multi-User & Access Control** β€” Granular permissions and network-based access restrictions\ +βœ… **Plugin System** β€” Extend functionality with custom plugins for automation and integrations\ +βœ… **Fully Self-Hosted** β€” Total control, no third-party dependencies --- - # Screenshots -![image](https://github.com/user-attachments/assets/bf7bc40a-d0e6-4f9f-8029-65b27d4205f9) +
+ Channels + TV Guide + Stats & Monitoring + M3U & EPG Manager + VOD Library + Settings +
-![image](https://github.com/user-attachments/assets/0835fd92-f7dc-4773-bdb7-7f88fd2f882d) +--- -![image](https://github.com/user-attachments/assets/710f2bc4-250f-4161-a6ed-44d5082a30c4) +## πŸ› οΈ Troubleshooting & Help -![image](https://github.com/user-attachments/assets/68a38d78-8f61-4c27-88f8-c52ba93d460d) +- **General help?** Visit [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +- **Community support?** Join our [Discord](https://discord.gg/Sp45V5BcxU) -![image](https://github.com/user-attachments/assets/63686b9a-6faf-43a3-ae7a-c9e10a216b5b) +--- - - - -# πŸš€ Get Started in Minutes +## πŸš€ Get Started in Minutes ### 🐳 Quick Start with Docker (Recommended) @@ -72,42 +98,50 @@ docker run -d \ --- -### 🐳 Docker Compose Options +### πŸ‹ Docker Compose Options -| Use Case | File | Description | -| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution β€” everything runs in a single container for quick setup. | -| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, and Postgres β€” perfect if you want more granular control. | -| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | +| Use Case | File | Description | +| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution β€” everything runs in a single container for quick setup. | +| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, Redis, and Postgres β€” perfect if you want more granular control. | +| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | --- -### βš’οΈ Building from Source (For the Adventurous) +### πŸ› οΈ Building from Source > ⚠️ **Warning**: Not officially supported β€” but if you're here, you know what you're doing! -If you are running a Debian based operating system you can install using the `debian_install.sh` script. If you are on another operating system and come up with a script let us know! We would love to add it here! +If you are running a Debian-based OS, use the `debian_install.sh` script. For other OS, contribute a script and we’ll add it! --- ## 🀝 Want to Contribute? We welcome **PRs, issues, ideas, and suggestions**!\ -Here’s how you can join the party: +Here's how you can join the party: - Follow our coding style and best practices. - Be respectful, helpful, and open-minded. - Respect the **CC BY-NC-SA license**. -> Whether it’s writing docs, squashing bugs, or building new features, your contribution matters! πŸ™Œ +> Whether it's writing docs, squashing bugs, or building new features, your contribution matters! πŸ™‹ --- -## πŸ“š Roadmap & Documentation +## πŸ“š Documentation & Roadmap -- πŸ“š **Roadmap:** Coming soon! - πŸ“– **Documentation:** [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +**Upcoming Features (in no particular order):** + +- 🎬 **VOD Management Enhancements** β€” Granular metadata control and cleanup of unwanted VOD content +- πŸ“ **Media Library** β€” Import local files and serve them over XC API +- πŸ‘₯ **Enhanced User Management** β€” Customizable XC API output per user account +- πŸ”„ **Output Stream Profiles** β€” Different clients with different stream profiles (bandwidth control, quality tiers) +- πŸ”Œ **Fallback Videos** β€” Automatic fallback content when channels are unavailable +- πŸ“‘ **Webhooks** β€” Event-driven integrations and automations + --- ## ❀️ Shoutouts @@ -120,7 +154,7 @@ A huge thank you to all the incredible open-source projects and libraries that p > Dispatcharr is licensed under **CC BY-NC-SA 4.0**: -- **BY**: Give credit where credit’s due. +- **BY**: Give credit where credit's due. - **NC**: No commercial use. - **SA**: Share alike if you remix. @@ -131,8 +165,8 @@ For full license details, see [LICENSE](https://creativecommons.org/licenses/by- ## βœ‰οΈ Connect With Us Have a question? Want to suggest a feature? Just want to say hi?\ -➑️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord]( https://discord.gg/Sp45V5BcxU). +➑️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord](https://discord.gg/Sp45V5BcxU). --- -### πŸš€ *Happy Streaming! The Dispatcharr Team* +### πŸš€ _Happy Streaming! The Dispatcharr Team_ diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py index db5b3d57..df51744d 100644 --- a/apps/proxy/ts_proxy/server.py +++ b/apps/proxy/ts_proxy/server.py @@ -149,11 +149,15 @@ class ProxyServer: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) pubsub_client = redis.Redis( host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=60, socket_connect_timeout=10, socket_keepalive=True, diff --git a/apps/proxy/vod_proxy/connection_manager.py b/apps/proxy/vod_proxy/connection_manager.py index ec0bffa5..aafc75cd 100644 --- a/apps/proxy/vod_proxy/connection_manager.py +++ b/apps/proxy/vod_proxy/connection_manager.py @@ -101,7 +101,16 @@ class PersistentVODConnection: redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{self.session_id}" stored_length = r.get(content_length_key) if stored_length: diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 2ec95cc3..948604d6 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -333,7 +333,16 @@ class VODStreamView(View): redis_host = getattr(settings, 'REDIS_HOST', 'localhost') redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) redis_db = int(getattr(settings, 'REDIS_DB', 0)) - r = redis.StrictRedis(host=redis_host, port=redis_port, db=redis_db, decode_responses=True) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True + ) content_length_key = f"vod_content_length:{session_id}" r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") diff --git a/core/utils.py b/core/utils.py index 98e2ba78..bd80782e 100644 --- a/core/utils.py +++ b/core/utils.py @@ -55,6 +55,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5) @@ -68,6 +70,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=socket_timeout, socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, @@ -148,6 +152,8 @@ class RedisClient: redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) # Use standardized settings but without socket timeouts for PubSub # Important: socket_timeout is None for PubSub operations @@ -161,6 +167,8 @@ class RedisClient: host=redis_host, port=redis_port, db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, socket_timeout=None, # Critical: No timeout for PubSub operations socket_connect_timeout=socket_connect_timeout, socket_keepalive=socket_keepalive, diff --git a/core/views.py b/core/views.py index 5806d63c..082cccc3 100644 --- a/core/views.py +++ b/core/views.py @@ -40,7 +40,15 @@ def stream_view(request, channel_uuid): redis_host = getattr(settings, "REDIS_HOST", "localhost") redis_port = int(getattr(settings, "REDIS_PORT", 6379)) redis_db = int(getattr(settings, "REDIS_DB", "0")) - redis_client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = getattr(settings, "REDIS_PASSWORD", "") + redis_user = getattr(settings, "REDIS_USER", "") + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) # Retrieve the channel by the provided stream_id. channel = Channel.objects.get(uuid=channel_uuid) diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py index 27d480be..2ed72bf4 100644 --- a/dispatcharr/persistent_lock.py +++ b/dispatcharr/persistent_lock.py @@ -78,7 +78,15 @@ if __name__ == "__main__": redis_host = os.environ.get("REDIS_HOST", "localhost") redis_port = int(os.environ.get("REDIS_PORT", 6379)) redis_db = int(os.environ.get("REDIS_DB", 0)) - client = redis.Redis(host=redis_host, port=redis_port, db=redis_db) + redis_password = os.environ.get("REDIS_PASSWORD", "") + redis_user = os.environ.get("REDIS_USER", "") + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None + ) lock = PersistentLock(client, "lock:example_account", lock_timeout=120) if lock.acquire(): diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 7afa75c5..8fba3143 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -1,6 +1,7 @@ import os from pathlib import Path from datetime import timedelta +from urllib.parse import quote_plus BASE_DIR = Path(__file__).resolve().parent.parent @@ -8,6 +9,8 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) REDIS_DB = os.environ.get("REDIS_DB", "0") +REDIS_USER = os.environ.get("REDIS_USER", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": @@ -121,7 +124,12 @@ CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [(REDIS_HOST, REDIS_PORT, REDIS_DB)], # Ensure Redis is running + "hosts": ["redis://{redis_auth}{host}:{port}/{db}".format( + redis_auth=f"{quote_plus(REDIS_USER)}:{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD and REDIS_USER else f":{quote_plus(REDIS_PASSWORD)}@" if REDIS_PASSWORD else "", + host=REDIS_HOST, + port=REDIS_PORT, + db=REDIS_DB + )], # URL format supports authentication }, }, } @@ -199,8 +207,19 @@ STATICFILES_DIRS = [ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" -# Build default Redis URL from components for Celery -_default_redis_url = f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# Build default Redis URL from components for Celery with optional authentication +# Build auth string conditionally with URL encoding for special characters +if REDIS_PASSWORD: + encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + encoded_user = quote_plus(REDIS_USER) + redis_auth = f"{encoded_user}:{encoded_password}@" + else: + redis_auth = f":{encoded_password}@" +else: + redis_auth = "" + +_default_redis_url = f"redis://{redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_redis_url) CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) @@ -271,7 +290,7 @@ SIMPLE_JWT = { } # Redis connection settings -REDIS_URL = os.environ.get("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") +REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c8e2d53e..519b288a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,11 @@ +# Dispatcharr - Modular Deployment Configuration +# This compose file runs Dispatcharr in modular mode with separate containers +# for web, celery workers, PostgreSQL database, and Redis cache. + services: + # ============================================================================ + # Web Service + # ============================================================================ web: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_web @@ -9,44 +16,67 @@ services: depends_on: - db - redis + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info + # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) - # that lack support for newer baseline CPU features + # that lack support for newer baseline CPU features: #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) - # Negative values require cap_add: SYS_NICE (uncomment below) + # Negative values require cap_add: SYS_NICE (see below) #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) - # + + # --- Advanced Configuration --- # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - # Optional for hardware acceleration + + # --- Hardware Acceleration (Optional) --- + # Uncomment for GPU access (transcoding acceleration) #group_add: # - video - # #- render # Uncomment if your GPU requires it + # #- render # Uncomment if your GPU requires it #devices: # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) + + # NVIDIA GPU Support (requires NVIDIA Container Toolkit) # Uncomment the following lines for NVIDIA GPU support - # NVidia GPU support (requires NVIDIA Container Toolkit) #deploy: # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + # ============================================================================ + # Celery Service - Background task worker + # ============================================================================ celery: image: ghcr.io/dispatcharr/dispatcharr:latest container_name: dispatcharr_celery @@ -58,24 +88,47 @@ services: - ./data:/data extra_hosts: - "host.docker.internal:host-gateway" + entrypoint: ["/app/docker/entrypoint.celery.sh"] + + # --- Environment Configuration --- environment: + # Deployment Mode - DISPATCHARR_ENV=modular + + # PostgreSQL Connection - POSTGRES_HOST=db - POSTGRES_PORT=5432 - POSTGRES_DB=dispatcharr - POSTGRES_USER=dispatch - POSTGRES_PASSWORD=secret + + # Redis Connection - REDIS_HOST=redis - REDIS_PORT=6379 + + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + + # Logging - DISPATCHARR_LOG_LEVEL=info - #- CELERY_NICE_LEVEL=5 #Celery/EPG/Background tasks (default:5, low priority; Range: -20 to 19) + + # Process Priority Configuration (Optional) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + + # Django Configuration - DJANGO_SETTINGS_MODULE=dispatcharr.settings - PYTHONUNBUFFERED=1 + + # --- Advanced Configuration --- # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) #cap_add: # - SYS_NICE - entrypoint: ["/app/docker/entrypoint.celery.sh"] + # ============================================================================ + # PostgreSQL + # ============================================================================ db: image: postgres:17 container_name: dispatcharr_db @@ -93,14 +146,42 @@ services: timeout: 5s retries: 5 + # ============================================================================ + # Redis + # ============================================================================ redis: image: redis:latest container_name: dispatcharr_redis + + # --- Authentication Configuration (Optional) --- + # By default, Redis runs without authentication. + # Choose ONE of the following options if authentication is required: + + # Option 1: Password-only authentication (Redis <6 or default user) + #command: ["redis-server", "--requirepass", "your_strong_redis_password"] + + # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration) + #command: ["redis-server", "/etc/redis/redis.conf"] + #volumes: + # - ./redis.conf:/etc/redis/redis.conf:ro + + # --- Health Check Configuration --- healthcheck: + # Default: No authentication test: ["CMD", "redis-cli", "ping"] + + # If using Option 1 (password-only), uncomment this instead: + #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"] + + # If using Option 2 (Redis 6+ ACL), uncomment this instead: + #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"] + interval: 5s timeout: 5s retries: 5 +# ============================================================================== +# Volumes +# ============================================================================== volumes: postgres_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ead6aadd..b79af952 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -38,6 +38,8 @@ export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" export REDIS_HOST=${REDIS_HOST:-localhost} export REDIS_PORT=${REDIS_PORT:-6379} export REDIS_DB=${REDIS_DB:-0} +export REDIS_PASSWORD=${REDIS_PASSWORD:-} +export REDIS_USER=${REDIS_USER:-} export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' export LD_LIBRARY_PATH='/usr/local/lib' @@ -104,7 +106,7 @@ if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL - REDIS_HOST REDIS_PORT REDIS_DB POSTGRES_DIR DISPATCHARR_PORT + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) @@ -161,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 @@ -189,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/docs/images/channels.png b/docs/images/channels.png new file mode 100644 index 00000000..87236f1f Binary files /dev/null and b/docs/images/channels.png differ diff --git a/docs/images/guide.png b/docs/images/guide.png new file mode 100644 index 00000000..37cfa132 Binary files /dev/null and b/docs/images/guide.png differ diff --git a/docs/images/m3u-epg-manager.png b/docs/images/m3u-epg-manager.png new file mode 100644 index 00000000..34bea87d Binary files /dev/null and b/docs/images/m3u-epg-manager.png differ diff --git a/docs/images/settings.png b/docs/images/settings.png new file mode 100644 index 00000000..9106fbe5 Binary files /dev/null and b/docs/images/settings.png differ diff --git a/docs/images/stats.png b/docs/images/stats.png new file mode 100644 index 00000000..a664bb7d Binary files /dev/null and b/docs/images/stats.png differ diff --git a/docs/images/vod-library.png b/docs/images/vod-library.png new file mode 100644 index 00000000..6effdb70 Binary files /dev/null and b/docs/images/vod-library.png differ diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ec2b712d..b7837cf2 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,7 +1,7 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; export default [ { ignores: ['dist'] }, @@ -30,4 +30,4 @@ export default [ ], }, }, -] +]; diff --git a/frontend/index.html b/frontend/index.html index 86207098..d2f6a575 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,10 +4,24 @@ - - - - + + + + Dispatcharr diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js index d2b86878..7787ae62 100644 --- a/frontend/prettier.config.js +++ b/frontend/prettier.config.js @@ -3,8 +3,9 @@ export default { semi: true, // Add semicolons at the end of statements singleQuote: true, // Use single quotes instead of double tabWidth: 2, // Set the indentation width - trailingComma: "es5", // Add trailing commas where valid in ES5 + trailingComma: 'es5', // Add trailing commas where valid in ES5 printWidth: 80, // Wrap lines at 80 characters bracketSpacing: true, // Add spaces inside object braces - arrowParens: "always", // Always include parentheses around arrow function parameters + arrowParens: 'always', // Always include parentheses around arrow function parameters + endOfLine: 'lf', // Enforce LF for all files }; diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest index 45dc8a20..fa99de77 100644 --- a/frontend/public/site.webmanifest +++ b/frontend/public/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/frontend/src/assets/site.webmanifest b/frontend/src/assets/site.webmanifest index 45dc8a20..fa99de77 100644 --- a/frontend/src/assets/site.webmanifest +++ b/frontend/src/assets/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx index 60c4ba38..83fa4de7 100644 --- a/frontend/src/components/ErrorBoundary.jsx +++ b/frontend/src/components/ErrorBoundary.jsx @@ -15,4 +15,4 @@ class ErrorBoundary extends React.Component { } } -export default ErrorBoundary; \ No newline at end of file +export default ErrorBoundary; diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 186ef7e0..6ee94243 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -1,4 +1,11 @@ -import { NumberInput, Select, Switch, Text, Textarea, TextInput } from '@mantine/core'; +import { + NumberInput, + Select, + Switch, + Text, + Textarea, + TextInput, +} from '@mantine/core'; import React from 'react'; export const Field = ({ field, value, onChange }) => { diff --git a/frontend/src/components/GuideRow.jsx b/frontend/src/components/GuideRow.jsx index 7780ecff..28f41563 100644 --- a/frontend/src/components/GuideRow.jsx +++ b/frontend/src/components/GuideRow.jsx @@ -1,13 +1,13 @@ -import React from "react"; +import React from 'react'; import { CHANNEL_WIDTH, EXPANDED_PROGRAM_HEIGHT, HOUR_WIDTH, PROGRAM_HEIGHT, } from '../pages/guideUtils.js'; -import {Box, Flex, Text} from "@mantine/core"; -import {Play} from "lucide-react"; -import logo from "../images/logo.png"; +import { Box, Flex, Text } from '@mantine/core'; +import { Play } from 'lucide-react'; +import logo from '../images/logo.png'; const GuideRow = React.memo(({ index, style, data }) => { const { @@ -36,31 +36,33 @@ const GuideRow = React.memo(({ index, style, data }) => { : PROGRAM_HEIGHT); const PlaceholderProgram = () => { - return <> - {Array.from({length: Math.ceil(24 / 2)}).map( - (_, placeholderIndex) => ( - - No program data - - ) - )} - ; - } + return ( + <> + {Array.from({ length: Math.ceil(24 / 2) }).map( + (_, placeholderIndex) => ( + + No program data + + ) + )} + + ); + }; return (
{ }} display={'flex'} h={'100%'} - pos='relative' + pos="relative" > { display={'flex'} left={0} h={'100%'} - pos='relative' + pos="relative" onClick={(event) => handleLogoClick(channel, event)} onMouseEnter={() => setHoveredChannelId(channel.id)} onMouseLeave={() => setHoveredChannelId(null)} @@ -110,7 +112,7 @@ const GuideRow = React.memo(({ index, style, data }) => { zIndex: 10, animation: 'fadeIn 0.2s', }} - pos='absolute' + pos="absolute" top={0} left={0} right={0} @@ -133,7 +135,7 @@ const GuideRow = React.memo(({ index, style, data }) => { w={'100%'} h={'100%'} p={'4px'} - pos='relative' + pos="relative" > { alignItems: 'center', justifyContent: 'center', }} - pos='absolute' + pos="absolute" bottom={4} left={'50%'} p={'2px 8px'} @@ -188,7 +190,7 @@ const GuideRow = React.memo(({ index, style, data }) => { transition: 'height 0.2s ease', }} flex={1} - pos='relative' + pos="relative" h={'100%'} pl={0} > @@ -196,11 +198,13 @@ const GuideRow = React.memo(({ index, style, data }) => { channelPrograms.map((program) => renderProgram(program, undefined, channel) ) - ) : } + ) : ( + + )}
); }); -export default GuideRow; \ No newline at end of file +export default GuideRow; diff --git a/frontend/src/components/HourTimeline.jsx b/frontend/src/components/HourTimeline.jsx index 9fc03368..0d5e70b4 100644 --- a/frontend/src/components/HourTimeline.jsx +++ b/frontend/src/components/HourTimeline.jsx @@ -3,103 +3,102 @@ import { Box, Text } from '@mantine/core'; import { format } from '../utils/dateTimeUtils.js'; import { HOUR_WIDTH } from '../pages/guideUtils.js'; -const HourBlock = React.memo(({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => { - const { time, isNewDay } = hourData; +const HourBlock = React.memo( + ({ hourData, timeFormat, formatDayLabel, handleTimeClick }) => { + const { time, isNewDay } = hourData; - return ( - handleTimeClick(time, e)} - > - handleTimeClick(time, e)} > - {formatDayLabel(time)} + + {formatDayLabel(time)} + + {format(time, timeFormat)} + - {format(time, timeFormat)} - - - + - - {[15, 30, 45].map((minute) => ( - + {[15, 30, 45].map((minute) => ( + + ))} + + + ); + } +); + +const HourTimeline = React.memo( + ({ hourTimeline, timeFormat, formatDayLabel, handleTimeClick }) => { + return ( + <> + {hourTimeline.map((hourData) => ( + ))} - - - ); -}); - -const HourTimeline = React.memo(({ - hourTimeline, - timeFormat, - formatDayLabel, - handleTimeClick - }) => { - return ( - <> - {hourTimeline.map((hourData) => ( - - ))} - - ); -}); + + ); + } +); export default HourTimeline; diff --git a/frontend/src/components/RecordingSynopsis.jsx b/frontend/src/components/RecordingSynopsis.jsx index bf668afe..e8c92d79 100644 --- a/frontend/src/components/RecordingSynopsis.jsx +++ b/frontend/src/components/RecordingSynopsis.jsx @@ -1,4 +1,4 @@ -import { Text, } from '@mantine/core'; +import { Text } from '@mantine/core'; // Short preview that triggers the details modal when clicked const RecordingSynopsis = ({ description, onOpen }) => { @@ -23,4 +23,4 @@ const RecordingSynopsis = ({ description, onOpen }) => { ); }; -export default RecordingSynopsis; \ No newline at end of file +export default RecordingSynopsis; diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx index dfb5a521..b8e69af0 100644 --- a/frontend/src/components/cards/PluginCard.jsx +++ b/frontend/src/components/cards/PluginCard.jsx @@ -37,7 +37,6 @@ const PluginActionList = ({ runningActionId, handlePluginRun, }) => { - console.log(plugin); return plugin.actions.map((action) => (
diff --git a/frontend/src/components/cards/SeriesCard.jsx b/frontend/src/components/cards/SeriesCard.jsx index f010cb44..0d2c7ef3 100644 --- a/frontend/src/components/cards/SeriesCard.jsx +++ b/frontend/src/components/cards/SeriesCard.jsx @@ -8,8 +8,8 @@ import { Stack, Text, } from '@mantine/core'; -import {Calendar, Play, Star} from "lucide-react"; -import React from "react"; +import { Calendar, Play, Star } from 'lucide-react'; +import React from 'react'; const SeriesCard = ({ series, onClick }) => { return ( @@ -82,4 +82,4 @@ const SeriesCard = ({ series, onClick }) => { ); }; -export default SeriesCard; \ No newline at end of file +export default SeriesCard; diff --git a/frontend/src/components/cards/VODCard.jsx b/frontend/src/components/cards/VODCard.jsx index 42468dae..ff7c86bd 100644 --- a/frontend/src/components/cards/VODCard.jsx +++ b/frontend/src/components/cards/VODCard.jsx @@ -140,4 +140,4 @@ const VODCard = ({ vod, onClick }) => { ); }; -export default VODCard; \ No newline at end of file +export default VODCard; diff --git a/frontend/src/components/forms/ProgramRecordingModal.jsx b/frontend/src/components/forms/ProgramRecordingModal.jsx index 777c39d0..9503f26b 100644 --- a/frontend/src/components/forms/ProgramRecordingModal.jsx +++ b/frontend/src/components/forms/ProgramRecordingModal.jsx @@ -74,23 +74,33 @@ export default function ProgramRecordingModal({ Just this one - - {recording && ( <> - - - - - ))} + {rules && + rules.map((r) => ( + + + {r.title || r.tvg_id} β€”{' '} + {r.mode === 'new' ? 'New episodes' : 'Every episode'} + + + + + + + ))} ); diff --git a/frontend/src/components/modals/EPGMatchModal.jsx b/frontend/src/components/modals/EPGMatchModal.jsx index 64873643..8e987de7 100644 --- a/frontend/src/components/modals/EPGMatchModal.jsx +++ b/frontend/src/components/modals/EPGMatchModal.jsx @@ -22,10 +22,14 @@ const getEpgSettingsFromStore = (settings) => { const epgSettings = settings?.['epg_settings']?.value; return { epg_match_mode: epgSettings?.epg_match_mode || 'default', - epg_match_ignore_prefixes: Array.isArray(epgSettings?.epg_match_ignore_prefixes) + epg_match_ignore_prefixes: Array.isArray( + epgSettings?.epg_match_ignore_prefixes + ) ? epgSettings.epg_match_ignore_prefixes : [], - epg_match_ignore_suffixes: Array.isArray(epgSettings?.epg_match_ignore_suffixes) + epg_match_ignore_suffixes: Array.isArray( + epgSettings?.epg_match_ignore_suffixes + ) ? epgSettings.epg_match_ignore_suffixes : [], epg_match_ignore_custom: Array.isArray(epgSettings?.epg_match_ignore_custom) @@ -34,11 +38,7 @@ const getEpgSettingsFromStore = (settings) => { }; }; -const EPGMatchModal = ({ - opened, - onClose, - selectedChannelIds = [], -}) => { +const EPGMatchModal = ({ opened, onClose, selectedChannelIds = [] }) => { const settings = useSettingsStore((s) => s.settings); const [loading, setLoading] = useState(false); @@ -107,9 +107,10 @@ const EPGMatchModal = ({ } }; - const scopeText = selectedChannelIds.length > 0 - ? `${selectedChannelIds.length} selected channel(s)` - : 'all channels without EPG'; + const scopeText = + selectedChannelIds.length > 0 + ? `${selectedChannelIds.length} selected channel(s)` + : 'all channels without EPG'; return ( - Channel display names are never modified. These settings only affect - the matching algorithm. + Channel display names are never modified. These settings only + affect the matching algorithm. )} diff --git a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx index 3f149347..002b5a0c 100644 --- a/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx +++ b/frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx @@ -43,7 +43,14 @@ vi.mock('../../../store/settings', () => ({ vi.mock('@mantine/core', () => { const React = require('react'); - const RadioComponent = ({ label, value, checked, description, groupValue, groupOnChange }) => { + const RadioComponent = ({ + label, + value, + checked, + description, + groupValue, + groupOnChange, + }) => { const isChecked = checked !== undefined ? checked : groupValue === value; const handleChange = groupOnChange || (() => {}); @@ -67,17 +74,26 @@ vi.mock('@mantine/core', () => { const enhancedChildren = React.Children.map(children, (child) => { if (React.isValidElement(child)) { // If it's a Stack or other container, recursively enhance its children - if (child.type?.name === 'Stack' || child.props['data-testid'] === 'stack') { + if ( + child.type?.name === 'Stack' || + child.props['data-testid'] === 'stack' + ) { return React.cloneElement(child, { - children: React.Children.map(child.props.children, (nestedChild) => { - if (React.isValidElement(nestedChild) && nestedChild.type === RadioComponent) { - return React.cloneElement(nestedChild, { - groupValue: value, - groupOnChange: onChange, - }); + children: React.Children.map( + child.props.children, + (nestedChild) => { + if ( + React.isValidElement(nestedChild) && + nestedChild.type === RadioComponent + ) { + return React.cloneElement(nestedChild, { + groupValue: value, + groupOnChange: onChange, + }); + } + return nestedChild; } - return nestedChild; - }), + ), }); } // If it's a Radio component, inject props directly @@ -157,7 +173,9 @@ describe('EPGMatchModal', () => { it('should not show advanced fields in default mode', () => { render(); - expect(screen.queryByLabelText('Ignore Prefixes')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Ignore Prefixes') + ).not.toBeInTheDocument(); }); it('should show advanced fields when advanced mode is selected', async () => { @@ -169,7 +187,9 @@ describe('EPGMatchModal', () => { await waitFor(() => { expect(screen.getByLabelText('Ignore Prefixes')).toBeInTheDocument(); expect(screen.getByLabelText('Ignore Suffixes')).toBeInTheDocument(); - expect(screen.getByLabelText('Ignore Custom Strings')).toBeInTheDocument(); + expect( + screen.getByLabelText('Ignore Custom Strings') + ).toBeInTheDocument(); }); }); }); @@ -199,7 +219,9 @@ describe('EPGMatchModal', () => { describe('Form Submission', () => { it('should save mode and trigger auto-match', async () => { - SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' }); + SettingsUtils.getChangedSettings.mockReturnValue({ + epg_match_mode: 'default', + }); SettingsUtils.saveChangedSettings.mockResolvedValue(); API.matchEpg.mockResolvedValue(); @@ -220,7 +242,9 @@ describe('EPGMatchModal', () => { SettingsUtils.getChangedSettings.mockReturnValue({}); API.matchEpg.mockResolvedValue(); - render(); + render( + + ); const submitButton = screen.getByText('Start Auto-Match'); fireEvent.click(submitButton); @@ -232,7 +256,9 @@ describe('EPGMatchModal', () => { it('should handle save errors gracefully', async () => { const error = new Error('Save failed'); - SettingsUtils.getChangedSettings.mockReturnValue({ epg_match_mode: 'default' }); + SettingsUtils.getChangedSettings.mockReturnValue({ + epg_match_mode: 'default', + }); SettingsUtils.saveChangedSettings.mockRejectedValue(error); render(); @@ -270,13 +296,23 @@ describe('EPGMatchModal', () => { describe('UI Text', () => { it('should show correct text for selected channels', () => { - render(); - expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument(); + render( + + ); + expect( + screen.getByText( + /Match channels to EPG data for 3 selected channel\(s\)/ + ) + ).toBeInTheDocument(); }); it('should show correct text for all channels', () => { render(); - expect(screen.getByText(/Match channels to EPG data for all channels without EPG/)).toBeInTheDocument(); + expect( + screen.getByText( + /Match channels to EPG data for all channels without EPG/ + ) + ).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css index e5a14cba..ccbf88e9 100644 --- a/frontend/src/components/sidebar.css +++ b/frontend/src/components/sidebar.css @@ -14,9 +14,9 @@ /* Active state styles */ .navlink.navlink-active { - color: #FFFFFF; + color: #ffffff; background-color: #245043; - border: 1px solid #3BA882; + border: 1px solid #3ba882; } /* Hover effect */ @@ -27,8 +27,8 @@ /* Hover effect for active state */ .navlink.navlink-active:hover { - background-color: #3A3A40; - border: 1px solid #3BA882; + background-color: #3a3a40; + border: 1px solid #3ba882; } /* Collapse condition for justifyContent */ diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index 4e549699..6b33f64f 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -180,7 +180,6 @@ const ChannelTableHeader = ({ } }; - const assignChannels = async () => { try { // Call our custom API endpoint diff --git a/frontend/src/components/tables/table.css b/frontend/src/components/tables/table.css index 30fd033a..88869f57 100644 --- a/frontend/src/components/tables/table.css +++ b/frontend/src/components/tables/table.css @@ -92,7 +92,7 @@ html { opacity: 0; } - *:hover>.resizer { + *:hover > .resizer { opacity: 1; } } @@ -104,7 +104,7 @@ html { /* .table-striped .tbody .tr:nth-child(even), */ .table-striped .tbody .tr-even { - background-color: #27272A; + background-color: #27272a; } /* Style for rows with no streams */ @@ -138,7 +138,7 @@ html { /* Always allow text selection in editable elements */ .shift-key-active input, .shift-key-active textarea, -.shift-key-active [contenteditable="true"], +.shift-key-active [contenteditable='true'], .shift-key-active .table-input-header input { user-select: text !important; -webkit-user-select: text !important; @@ -169,7 +169,7 @@ html { /* Always allow text selection in inputs even when shift is pressed */ .shift-key-active input, .shift-key-active textarea, -.shift-key-active [contenteditable="true"], +.shift-key-active [contenteditable='true'], .shift-key-active select, .shift-key-active .mantine-Select-input, .shift-key-active .mantine-MultiSelect-input, diff --git a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx index 8be21e28..763f38a1 100644 --- a/frontend/src/hooks/__tests__/useLocalStorage.test.jsx +++ b/frontend/src/hooks/__tests__/useLocalStorage.test.jsx @@ -16,7 +16,7 @@ const localStorageMock = (() => { }), removeItem: vi.fn((key) => { delete store[key]; - }) + }), }; })(); @@ -32,7 +32,9 @@ describe('useLocalStorage', () => { }); it('should initialize with default value when localStorage is empty', () => { - const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + const { result } = renderHook(() => + useLocalStorage('testKey', 'defaultValue') + ); expect(result.current[0]).toBe('defaultValue'); }); @@ -40,7 +42,9 @@ describe('useLocalStorage', () => { it('should initialize with value from localStorage if available', () => { localStorageMock.setItem('testKey', JSON.stringify('storedValue')); - const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + const { result } = renderHook(() => + useLocalStorage('testKey', 'defaultValue') + ); expect(result.current[0]).toBe('storedValue'); }); @@ -52,14 +56,19 @@ describe('useLocalStorage', () => { result.current[1]('updated'); }); - expect(localStorageMock.setItem).toHaveBeenCalledWith('testKey', JSON.stringify('updated')); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'testKey', + JSON.stringify('updated') + ); expect(result.current[0]).toBe('updated'); }); it('should handle complex objects', () => { const complexObject = { name: 'test', count: 42, nested: { value: true } }; - const { result } = renderHook(() => useLocalStorage('testKey', complexObject)); + const { result } = renderHook(() => + useLocalStorage('testKey', complexObject) + ); act(() => { result.current[1]({ name: 'updated', count: 100 }); @@ -73,7 +82,9 @@ describe('useLocalStorage', () => { throw new Error('Read error'); }); - const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + const { result } = renderHook(() => + useLocalStorage('testKey', 'defaultValue') + ); expect(result.current[0]).toBe('defaultValue'); expect(console.error).toHaveBeenCalledWith( @@ -102,7 +113,9 @@ describe('useLocalStorage', () => { it('should handle invalid JSON in localStorage', () => { localStorageMock.getItem.mockReturnValueOnce('invalid json{'); - const { result } = renderHook(() => useLocalStorage('testKey', 'defaultValue')); + const { result } = renderHook(() => + useLocalStorage('testKey', 'defaultValue') + ); expect(result.current[0]).toBe('defaultValue'); expect(console.error).toHaveBeenCalled(); diff --git a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx index d8ef2711..66331b44 100644 --- a/frontend/src/hooks/__tests__/useSmartLogos.test.jsx +++ b/frontend/src/hooks/__tests__/useSmartLogos.test.jsx @@ -1,6 +1,10 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { useLogoSelection, useChannelLogoSelection, useLogosById } from '../useSmartLogos'; +import { + useLogoSelection, + useChannelLogoSelection, + useLogosById, +} from '../useSmartLogos'; import useLogosStore from '../../store/logos'; // Mock the logos store @@ -13,10 +17,12 @@ describe('useSmartLogos', () => { }); it('should initialize with empty state', () => { - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogos: vi.fn(), - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogos: vi.fn(), + }) + ); const { result } = renderHook(() => useLogoSelection()); @@ -27,10 +33,12 @@ describe('useSmartLogos', () => { it('should load logos when ensureLogosLoaded is called', async () => { const mockFetchLogos = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogos: mockFetchLogos, - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogos: mockFetchLogos, + }) + ); const { result } = renderHook(() => useLogoSelection()); @@ -43,10 +51,12 @@ describe('useSmartLogos', () => { it('should not reload logos if already loaded', async () => { const mockFetchLogos = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - logos: { logo1: { id: 'logo1' } }, - fetchLogos: mockFetchLogos, - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: { logo1: { id: 'logo1' } }, + fetchLogos: mockFetchLogos, + }) + ); const { result } = renderHook(() => useLogoSelection()); @@ -62,12 +72,18 @@ describe('useSmartLogos', () => { }); it('should handle errors when fetching logos', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const mockFetchLogos = vi.fn().mockRejectedValue(new Error('Fetch failed')); - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogos: mockFetchLogos, - })); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockFetchLogos = vi + .fn() + .mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogos: mockFetchLogos, + }) + ); const { result } = renderHook(() => useLogoSelection()); @@ -84,10 +100,12 @@ describe('useSmartLogos', () => { }); it('should indicate hasLogos when logos are present', () => { - useLogosStore.mockImplementation((selector) => selector({ - logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } }, - fetchLogos: vi.fn(), - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: { logo1: { id: 'logo1' }, logo2: { id: 'logo2' } }, + fetchLogos: vi.fn(), + }) + ); const { result } = renderHook(() => useLogoSelection()); @@ -101,12 +119,14 @@ describe('useSmartLogos', () => { }); it('should initialize with channel logos state', () => { - useLogosStore.mockImplementation((selector) => selector({ - channelLogos: {}, - hasLoadedChannelLogos: false, - backgroundLoading: false, - fetchChannelAssignableLogos: vi.fn(), - })); + useLogosStore.mockImplementation((selector) => + selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: vi.fn(), + }) + ); const { result } = renderHook(() => useChannelLogoSelection()); @@ -117,12 +137,14 @@ describe('useSmartLogos', () => { it('should load channel logos when ensureLogosLoaded is called', async () => { const mockFetchChannelLogos = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - channelLogos: {}, - hasLoadedChannelLogos: false, - backgroundLoading: false, - fetchChannelAssignableLogos: mockFetchChannelLogos, - })); + useLogosStore.mockImplementation((selector) => + selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + }) + ); const { result } = renderHook(() => useChannelLogoSelection()); @@ -135,12 +157,14 @@ describe('useSmartLogos', () => { it('should not reload if already loaded', async () => { const mockFetchChannelLogos = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - channelLogos: { logo1: { id: 'logo1' } }, - hasLoadedChannelLogos: true, - backgroundLoading: false, - fetchChannelAssignableLogos: mockFetchChannelLogos, - })); + useLogosStore.mockImplementation((selector) => + selector({ + channelLogos: { logo1: { id: 'logo1' } }, + hasLoadedChannelLogos: true, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + }) + ); const { result } = renderHook(() => useChannelLogoSelection()); @@ -153,12 +177,14 @@ describe('useSmartLogos', () => { it('should not load if backgroundLoading is true', async () => { const mockFetchChannelLogos = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - channelLogos: {}, - hasLoadedChannelLogos: false, - backgroundLoading: true, - fetchChannelAssignableLogos: mockFetchChannelLogos, - })); + useLogosStore.mockImplementation((selector) => + selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: true, + fetchChannelAssignableLogos: mockFetchChannelLogos, + }) + ); const { result } = renderHook(() => useChannelLogoSelection()); @@ -170,14 +196,20 @@ describe('useSmartLogos', () => { }); it('should handle errors when fetching channel logos', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const mockFetchChannelLogos = vi.fn().mockRejectedValue(new Error('Fetch failed')); - useLogosStore.mockImplementation((selector) => selector({ - channelLogos: {}, - hasLoadedChannelLogos: false, - backgroundLoading: false, - fetchChannelAssignableLogos: mockFetchChannelLogos, - })); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockFetchChannelLogos = vi + .fn() + .mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => + selector({ + channelLogos: {}, + hasLoadedChannelLogos: false, + backgroundLoading: false, + fetchChannelAssignableLogos: mockFetchChannelLogos, + }) + ); const { result } = renderHook(() => useChannelLogoSelection()); @@ -200,10 +232,12 @@ describe('useSmartLogos', () => { }); it('should initialize with empty logos', () => { - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogosByIds: vi.fn(), - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogosByIds: vi.fn(), + }) + ); const { result } = renderHook(() => useLogosById([])); @@ -214,10 +248,12 @@ describe('useSmartLogos', () => { it('should fetch missing logos by IDs', async () => { const mockFetchLogosByIds = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogosByIds: mockFetchLogosByIds, - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + }) + ); renderHook(() => useLogosById(['logo1', 'logo2'])); @@ -228,10 +264,12 @@ describe('useSmartLogos', () => { it('should not fetch logos that are already loaded', async () => { const mockFetchLogosByIds = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - logos: { logo1: { id: 'logo1' } }, - fetchLogosByIds: mockFetchLogosByIds, - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: { logo1: { id: 'logo1' } }, + fetchLogosByIds: mockFetchLogosByIds, + }) + ); renderHook(() => useLogosById(['logo1', 'logo2'])); @@ -241,12 +279,18 @@ describe('useSmartLogos', () => { }); it('should handle errors when fetching logos by IDs', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const mockFetchLogosByIds = vi.fn().mockRejectedValue(new Error('Fetch failed')); - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogosByIds: mockFetchLogosByIds, - })); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockFetchLogosByIds = vi + .fn() + .mockRejectedValue(new Error('Fetch failed')); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + }) + ); renderHook(() => useLogosById(['logo1'])); @@ -262,10 +306,12 @@ describe('useSmartLogos', () => { it('should filter out null/undefined IDs', async () => { const mockFetchLogosByIds = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogosByIds: mockFetchLogosByIds, - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + }) + ); renderHook(() => useLogosById(['logo1', null, undefined, 'logo2'])); @@ -276,10 +322,12 @@ describe('useSmartLogos', () => { it('should not refetch the same IDs multiple times', async () => { const mockFetchLogosByIds = vi.fn().mockResolvedValue(); - useLogosStore.mockImplementation((selector) => selector({ - logos: {}, - fetchLogosByIds: mockFetchLogosByIds, - })); + useLogosStore.mockImplementation((selector) => + selector({ + logos: {}, + fetchLogosByIds: mockFetchLogosByIds, + }) + ); const { rerender } = renderHook(() => useLogosById(['logo1'])); diff --git a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx index 2158df54..458b8714 100644 --- a/frontend/src/hooks/__tests__/useTablePreferences.test.jsx +++ b/frontend/src/hooks/__tests__/useTablePreferences.test.jsx @@ -16,7 +16,7 @@ const localStorageMock = (() => { }), removeItem: vi.fn((key) => { delete store[key]; - }) + }), }; })(); @@ -50,7 +50,10 @@ describe('useTablePreferences', () => { }); it('should initialize headerPinned from localStorage', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); const { result } = renderHook(() => useTablePreferences()); @@ -58,7 +61,10 @@ describe('useTablePreferences', () => { }); it('should initialize tableSize from localStorage', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ tableSize: 'compact' }) + ); const { result } = renderHook(() => useTablePreferences()); @@ -66,7 +72,10 @@ describe('useTablePreferences', () => { }); it('should initialize both preferences from localStorage', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true, tableSize: 'comfortable' })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ headerPinned: true, tableSize: 'comfortable' }) + ); const { result } = renderHook(() => useTablePreferences()); @@ -83,7 +92,10 @@ describe('useTablePreferences', () => { }); it('should prefer new localStorage location over old location', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'comfortable' })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ tableSize: 'comfortable' }) + ); localStorageMock.setItem('table-size', JSON.stringify('compact')); const { result } = renderHook(() => useTablePreferences()); @@ -127,7 +139,10 @@ describe('useTablePreferences', () => { }); it('should preserve existing preferences when updating headerPinned', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ tableSize: 'compact' })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ tableSize: 'compact' }) + ); const { result } = renderHook(() => useTablePreferences()); @@ -205,7 +220,10 @@ describe('useTablePreferences', () => { }); it('should preserve existing preferences when updating tableSize', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); const { result } = renderHook(() => useTablePreferences()); @@ -320,7 +338,10 @@ describe('useTablePreferences', () => { }); it('should not update if value is the same', () => { - localStorageMock.setItem('table-preferences', JSON.stringify({ headerPinned: true })); + localStorageMock.setItem( + 'table-preferences', + JSON.stringify({ headerPinned: true }) + ); const { result } = renderHook(() => useTablePreferences()); const initialHeaderPinned = result.current.headerPinned; diff --git a/frontend/src/index.css b/frontend/src/index.css index 5e902b6c..324f43fc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -7,9 +7,9 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', + 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: #18181b; @@ -18,8 +18,8 @@ body { } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; + font-family: + source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } /* Example scrollbars - optional, to match a dark theme. */ @@ -28,7 +28,7 @@ code { } ::-webkit-scrollbar-track { - background: #3B3C41; + background: #3b3c41; } ::-webkit-scrollbar-thumb { @@ -45,7 +45,9 @@ code { } } -table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Table-td-detail-panel { +table.mrt-table + tr.mantine-Table-tr.mantine-Table-tr-detail-panel + td.mantine-Table-td-detail-panel { width: 100% !important; } @@ -71,7 +73,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab /* Create a short vertical bar */ .sash.sash-vertical::before { - content: ""; + content: ''; position: absolute; top: 50%; left: 50%; @@ -103,7 +105,7 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab } /* For elements that specifically need transforms (like draggable elements) */ -[data-draggable="true"] { +[data-draggable='true'] { transform: translate3d(0, 0, 0) !important; } @@ -137,4 +139,4 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} \ No newline at end of file +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 2a7b83c0..d76b7587 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -4,6 +4,6 @@ import App from './App'; ReactDOM.createRoot(document.getElementById('root')).render( - + ); diff --git a/frontend/src/pages/ContentSources.jsx b/frontend/src/pages/ContentSources.jsx index c9eaaffc..c412fe90 100644 --- a/frontend/src/pages/ContentSources.jsx +++ b/frontend/src/pages/ContentSources.jsx @@ -2,7 +2,7 @@ import useUserAgentsStore from '../store/userAgents'; import M3UsTable from '../components/tables/M3UsTable'; import EPGsTable from '../components/tables/EPGsTable'; import { Box, Stack } from '@mantine/core'; -import ErrorBoundary from '../components/ErrorBoundary' +import ErrorBoundary from '../components/ErrorBoundary'; const PageContent = () => { const error = useUserAgentsStore((state) => state.error); @@ -28,14 +28,14 @@ const PageContent = () => { ); -} +}; const M3UPage = () => { return ( - + ); -} +}; export default M3UPage; diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 7bd6e07f..7fa12b36 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -10,24 +10,23 @@ import { Title, useMantineTheme, } from '@mantine/core'; -import { - SquarePlus, -} from 'lucide-react'; +import { SquarePlus } from 'lucide-react'; import useChannelsStore from '../store/channels'; import useSettingsStore from '../store/settings'; import useVideoStore from '../store/useVideoStore'; import RecordingForm from '../components/forms/Recording'; -import { - isAfter, - isBefore, - useTimeHelpers, -} from '../utils/dateTimeUtils.js'; -const RecordingDetailsModal = lazy(() => - import('../components/forms/RecordingDetailsModal')); +import { isAfter, isBefore, useTimeHelpers } from '../utils/dateTimeUtils.js'; +const RecordingDetailsModal = lazy( + () => import('../components/forms/RecordingDetailsModal') +); import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx'; import RecordingCard from '../components/cards/RecordingCard.jsx'; import { categorizeRecordings } from '../utils/pages/DVRUtils.js'; -import { getPosterUrl, getRecordingUrl, getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; +import { + getPosterUrl, + getRecordingUrl, + getShowVideoUrl, +} from '../utils/cards/RecordingCardUtils.js'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; const RecordingList = ({ list, onOpenDetails, onOpenRecurring }) => { @@ -113,32 +112,35 @@ const DVRPage = () => { const now = userNow(); const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if(isAfter(now, s) && isBefore(now, e)) { + if (isAfter(now, s) && isBefore(now, e)) { // call into child RecordingCard behavior by constructing a URL like there const channel = channels[rec.channel]; if (!channel) return; - const url = getShowVideoUrl(channel, useSettingsStore.getState().environment.env_mode); + const url = getShowVideoUrl( + channel, + useSettingsStore.getState().environment.env_mode + ); useVideoStore.getState().showVideo(url, 'live'); } - } + }; const handleOnWatchRecording = () => { const url = getRecordingUrl( - detailsRecording.custom_properties, useSettingsStore.getState().environment.env_mode); - if(!url) return; + detailsRecording.custom_properties, + useSettingsStore.getState().environment.env_mode + ); + if (!url) return; useVideoStore.getState().showVideo(url, 'vod', { - name: - detailsRecording.custom_properties?.program?.title || - 'Recording', + name: detailsRecording.custom_properties?.program?.title || 'Recording', logo: { url: getPosterUrl( detailsRecording.custom_properties?.poster_logo_id, undefined, channels[detailsRecording.channel]?.logo?.cache_url - ) + ), }, }); - } + }; return ( ; + tabsComponent.Tab = ({ children, value }) => ( + + ); return { Box: ({ children, ...props }) =>
{children}
, @@ -113,7 +121,9 @@ describe('LogosPage', () => { }); it('shows error notification when fetching logos fails', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const error = new Error('Failed to fetch'); mockFetchAllLogos.mockRejectedValue(error); diff --git a/frontend/src/pages/__tests__/Plugins.test.jsx b/frontend/src/pages/__tests__/Plugins.test.jsx index 66e9b6f9..dd1f6771 100644 --- a/frontend/src/pages/__tests__/Plugins.test.jsx +++ b/frontend/src/pages/__tests__/Plugins.test.jsx @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import PluginsPage from '../Plugins'; -import { showNotification, updateNotification } from '../../utils/notificationUtils.js'; +import { + showNotification, + updateNotification, +} from '../../utils/notificationUtils.js'; import { deletePluginByKey, importPlugin, @@ -47,7 +50,16 @@ vi.mock('@mantine/core', async () => { {children} ), - Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => ( + Button: ({ + children, + onClick, + leftSection, + variant, + color, + loading, + disabled, + fullWidth, + }) => ( ), - SimpleGrid: ({ children, cols }) => ( -
{children}
- ), + SimpleGrid: ({ children, cols }) =>
{children}
, Modal: ({ opened, onClose, title, children, size, centered }) => opened ? (
@@ -109,7 +124,9 @@ vi.mock('@mantine/dropzone', () => ({ data-accept={accept} data-max-size={maxSize} onClick={() => { - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); onDrop([file]); }} > @@ -188,7 +205,11 @@ describe('PluginsPage', () => { }); it('shows loader when loading and no plugins', () => { - const loadingState = { plugins: [], loading: true, fetchPlugins: vi.fn() }; + const loadingState = { + plugins: [], + loading: true, + fetchPlugins: vi.fn(), + }; usePluginStore.mockImplementation((selector) => { return selector ? selector(loadingState) : loadingState; }); @@ -219,7 +240,9 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); expect(screen.getByTestId('modal')).toBeInTheDocument(); - expect(screen.getByTestId('modal-title')).toHaveTextContent('Import Plugin'); + expect(screen.getByTestId('modal-title')).toHaveTextContent( + 'Import Plugin' + ); }); it('shows dropzone and file input in import modal', () => { @@ -228,7 +251,9 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); expect(screen.getByTestId('dropzone')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Select plugin .zip')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Select plugin .zip') + ).toBeInTheDocument(); }); it('closes import modal when close button is clicked', () => { @@ -245,7 +270,11 @@ describe('PluginsPage', () => { it('handles file upload via dropzone', async () => { importPlugin.mockResolvedValue({ success: true, - plugin: { key: 'new-plugin', name: 'New Plugin', description: 'New Description' }, + plugin: { + key: 'new-plugin', + name: 'New Plugin', + description: 'New Description', + }, }); render(); @@ -255,9 +284,9 @@ describe('PluginsPage', () => { fireEvent.click(dropzone); await waitFor(() => { - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); expect(uploadButton).not.toBeDisabled(); }); }); @@ -279,12 +308,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -305,12 +336,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -340,12 +373,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -372,12 +407,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -387,9 +424,9 @@ describe('PluginsPage', () => { const enableSwitch = screen.getByRole('checkbox'); fireEvent.click(enableSwitch); - const enableButton = screen.getAllByText('Enable').find(btn => - btn.tagName === 'BUTTON' - ); + const enableButton = screen + .getAllByText('Enable') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(enableButton); await waitFor(() => { @@ -417,12 +454,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -432,13 +471,15 @@ describe('PluginsPage', () => { const enableSwitch = screen.getByRole('checkbox'); fireEvent.click(enableSwitch); - const enableButton = screen.getAllByText('Enable').find(btn => - btn.tagName === 'BUTTON' - ); + const enableButton = screen + .getAllByText('Enable') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(enableButton); await waitFor(() => { - expect(screen.getByText('Enable third-party plugins?')).toBeInTheDocument(); + expect( + screen.getByText('Enable third-party plugins?') + ).toBeInTheDocument(); }); }); @@ -460,12 +501,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -475,9 +518,9 @@ describe('PluginsPage', () => { const enableSwitch = screen.getByRole('checkbox'); fireEvent.click(enableSwitch); - const enableButton = screen.getAllByText('Enable').find(btn => - btn.tagName === 'BUTTON' - ); + const enableButton = screen + .getAllByText('Enable') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(enableButton); await waitFor(() => { @@ -508,12 +551,14 @@ describe('PluginsPage', () => { fireEvent.click(screen.getByText('Import Plugin')); const fileInput = screen.getByPlaceholderText('Select plugin .zip'); - const file = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const file = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); fireEvent.change(fileInput, { target: { files: [file] } }); - const uploadButton = screen.getAllByText('Upload').find(btn => - btn.tagName === 'BUTTON' - ); + const uploadButton = screen + .getAllByText('Upload') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(uploadButton); await waitFor(() => { @@ -523,9 +568,9 @@ describe('PluginsPage', () => { const enableSwitch = screen.getByRole('checkbox'); fireEvent.click(enableSwitch); - const enableButton = screen.getAllByText('Enable').find(btn => - btn.tagName === 'BUTTON' - ); + const enableButton = screen + .getAllByText('Enable') + .find((btn) => btn.tagName === 'BUTTON'); fireEvent.click(enableButton); await waitFor(() => { diff --git a/frontend/src/pages/__tests__/Users.test.jsx b/frontend/src/pages/__tests__/Users.test.jsx index 3ee63627..b7ab4ae2 100644 --- a/frontend/src/pages/__tests__/Users.test.jsx +++ b/frontend/src/pages/__tests__/Users.test.jsx @@ -5,7 +5,7 @@ import useAuthStore from '../../store/auth'; vi.mock('../../store/auth'); vi.mock('../../components/tables/UsersTable', () => ({ - default: () =>
UsersTable
+ default: () =>
UsersTable
, })); vi.mock('@mantine/core', () => ({ Box: ({ children, ...props }) =>
{children}
, diff --git a/frontend/src/pages/__tests__/VODs.test.jsx b/frontend/src/pages/__tests__/VODs.test.jsx index 6e7c00ec..a4d74337 100644 --- a/frontend/src/pages/__tests__/VODs.test.jsx +++ b/frontend/src/pages/__tests__/VODs.test.jsx @@ -16,7 +16,7 @@ vi.mock('../../components/SeriesModal', () => ({
{series?.name}
- ) : null + ) : null, })); vi.mock('../../components/VODModal', () => ({ default: ({ opened, vod, onClose }) => @@ -25,26 +25,30 @@ vi.mock('../../components/VODModal', () => ({
{vod?.name}
- ) : null + ) : null, })); vi.mock('../../components/cards/VODCard', () => ({ default: ({ vod, onClick }) => (
onClick(vod)}>
{vod.name}
- ) + ), })); vi.mock('../../components/cards/SeriesCard', () => ({ default: ({ series, onClick }) => (
onClick(series)}>
{series.name}
- ) + ), })); vi.mock('@mantine/core', () => { - const gridComponent = ({ children, ...props }) =>
{children}
; - gridComponent.Col = ({ children, ...props }) =>
{children}
; + const gridComponent = ({ children, ...props }) => ( +
{children}
+ ); + gridComponent.Col = ({ children, ...props }) => ( +
{children}
+ ); return { Box: ({ children, ...props }) =>
{children}
, @@ -97,7 +101,9 @@ vi.mock('@mantine/core', () => { - {page} of {total} + + {page} of {total} + @@ -208,9 +214,7 @@ describe('VODsPage', () => { it('renders series cards for series', async () => { const stateWithSeries = { ...defaultStoreState, - currentPageContent: [ - { id: 1, name: 'Series 1', contentType: 'series' }, - ], + currentPageContent: [{ id: 1, name: 'Series 1', contentType: 'series' }], }; useVODStore.mockImplementation((selector) => selector(stateWithSeries)); @@ -224,9 +228,7 @@ describe('VODsPage', () => { it('opens VOD modal when VOD card is clicked', async () => { const stateWithMovies = { ...defaultStoreState, - currentPageContent: [ - { id: 1, name: 'Test Movie', contentType: 'movie' }, - ], + currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }], }; useVODStore.mockImplementation((selector) => selector(stateWithMovies)); @@ -262,9 +264,7 @@ describe('VODsPage', () => { it('closes VOD modal when close button is clicked', async () => { const stateWithMovies = { ...defaultStoreState, - currentPageContent: [ - { id: 1, name: 'Test Movie', contentType: 'movie' }, - ], + currentPageContent: [{ id: 1, name: 'Test Movie', contentType: 'movie' }], }; useVODStore.mockImplementation((selector) => selector(stateWithMovies)); @@ -326,9 +326,7 @@ describe('VODsPage', () => { }); it('updates filters and resets page when category changes', async () => { - getCategoryOptions.mockReturnValue([ - { value: 'action', label: 'Action' }, - ]); + getCategoryOptions.mockReturnValue([{ value: 'action', label: 'Action' }]); render(); @@ -370,9 +368,7 @@ describe('VODsPage', () => { totalCount: 25, pageSize: 12, }; - useVODStore.mockImplementation((selector) => - selector(stateWithPagination) - ); + useVODStore.mockImplementation((selector) => selector(stateWithPagination)); render(); @@ -405,9 +401,7 @@ describe('VODsPage', () => { pageSize: 12, currentPage: 1, }; - useVODStore.mockImplementation((selector) => - selector(stateWithPagination) - ); + useVODStore.mockImplementation((selector) => selector(stateWithPagination)); render(); diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js index 01bbe846..8985be0d 100644 --- a/frontend/src/pages/__tests__/guideUtils.test.js +++ b/frontend/src/pages/__tests__/guideUtils.test.js @@ -63,9 +63,7 @@ describe('guideUtils', () => { }); it('should use tvg_id from EPG data for regular sources', () => { - const channels = [ - { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }, - ]; + const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }]; const tvgsById = { 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' }, }; @@ -79,9 +77,7 @@ describe('guideUtils', () => { }); it('should use channel UUID for dummy EPG sources', () => { - const channels = [ - { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }, - ]; + const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }]; const tvgsById = { 'epg-1': { tvg_id: 'tvg-123', epg_source: 'source-1' }, }; @@ -113,9 +109,7 @@ describe('guideUtils', () => { }); it('should fall back to UUID when tvg_id is null', () => { - const channels = [ - { id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }, - ]; + const channels = [{ id: 1, uuid: 'uuid-1', epg_data_id: 'epg-1' }]; const tvgsById = { 'epg-1': { tvg_id: null, epg_source: 'source-1' }, }; @@ -160,7 +154,10 @@ describe('guideUtils', () => { ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const result = guideUtils.mapProgramsByChannel( + programs, + channelIdByTvgId + ); expect(result.get(1)).toHaveLength(1); expect(result.get(1)[0]).toMatchObject({ @@ -185,7 +182,10 @@ describe('guideUtils', () => { ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const result = guideUtils.mapProgramsByChannel( + programs, + channelIdByTvgId + ); expect(result.get(1)[0]).toHaveProperty('startMs'); expect(result.get(1)[0]).toHaveProperty('endMs'); @@ -209,7 +209,10 @@ describe('guideUtils', () => { ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const result = guideUtils.mapProgramsByChannel( + programs, + channelIdByTvgId + ); expect(result.get(1)[0].isLive).toBe(true); expect(result.get(1)[0].isPast).toBe(false); @@ -233,7 +236,10 @@ describe('guideUtils', () => { ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const result = guideUtils.mapProgramsByChannel( + programs, + channelIdByTvgId + ); expect(result.get(1)[0].isLive).toBe(false); expect(result.get(1)[0].isPast).toBe(true); @@ -252,7 +258,10 @@ describe('guideUtils', () => { ]; const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); - const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const result = guideUtils.mapProgramsByChannel( + programs, + channelIdByTvgId + ); expect(result.get(1)).toHaveLength(1); expect(result.get(2)).toHaveLength(1); @@ -282,7 +291,10 @@ describe('guideUtils', () => { ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const result = guideUtils.mapProgramsByChannel( + programs, + channelIdByTvgId + ); expect(result.get(1)[0].id).toBe(1); expect(result.get(1)[1].id).toBe(2); @@ -300,9 +312,16 @@ describe('guideUtils', () => { const channels = [{ id: 1 }, { id: 2 }]; const programsByChannelId = new Map(); - const result = guideUtils.computeRowHeights(channels, programsByChannelId, null); + const result = guideUtils.computeRowHeights( + channels, + programsByChannelId, + null + ); - expect(result).toEqual([guideUtils.PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]); + expect(result).toEqual([ + guideUtils.PROGRAM_HEIGHT, + guideUtils.PROGRAM_HEIGHT, + ]); }); it('should return expanded height for channel with expanded program', () => { @@ -312,9 +331,16 @@ describe('guideUtils', () => { [2, [{ id: 'program-2' }]], ]); - const result = guideUtils.computeRowHeights(channels, programsByChannelId, 'program-1'); + const result = guideUtils.computeRowHeights( + channels, + programsByChannelId, + 'program-1' + ); - expect(result).toEqual([guideUtils.EXPANDED_PROGRAM_HEIGHT, guideUtils.PROGRAM_HEIGHT]); + expect(result).toEqual([ + guideUtils.EXPANDED_PROGRAM_HEIGHT, + guideUtils.PROGRAM_HEIGHT, + ]); }); it('should use custom heights when provided', () => { @@ -393,7 +419,13 @@ describe('guideUtils', () => { { id: 2, name: 'Channel 2' }, ]; - const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {}); + const result = guideUtils.filterGuideChannels( + channels, + '', + 'all', + 'all', + {} + ); expect(result).toHaveLength(2); }); @@ -404,7 +436,13 @@ describe('guideUtils', () => { { id: 2, name: 'CNN' }, ]; - const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {}); + const result = guideUtils.filterGuideChannels( + channels, + 'espn', + 'all', + 'all', + {} + ); expect(result).toHaveLength(1); expect(result[0].name).toBe('ESPN'); @@ -416,7 +454,13 @@ describe('guideUtils', () => { { id: 2, name: 'Channel 2', channel_group_id: 2 }, ]; - const result = guideUtils.filterGuideChannels(channels, '', '1', 'all', {}); + const result = guideUtils.filterGuideChannels( + channels, + '', + '1', + 'all', + {} + ); expect(result).toHaveLength(1); expect(result[0].channel_group_id).toBe(1); @@ -436,7 +480,13 @@ describe('guideUtils', () => { }, }; - const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles); + const result = guideUtils.filterGuideChannels( + channels, + '', + 'all', + 'profile1', + profiles + ); expect(result).toHaveLength(1); expect(result[0].id).toBe(1); @@ -453,7 +503,13 @@ describe('guideUtils', () => { }, }; - const result = guideUtils.filterGuideChannels(channels, '', 'all', 'profile1', profiles); + const result = guideUtils.filterGuideChannels( + channels, + '', + 'all', + 'profile1', + profiles + ); expect(result).toHaveLength(1); expect(result[0].id).toBe(1); @@ -474,7 +530,13 @@ describe('guideUtils', () => { }, }; - const result = guideUtils.filterGuideChannels(channels, 'espn', '1', 'profile1', profiles); + const result = guideUtils.filterGuideChannels( + channels, + 'espn', + '1', + 'profile1', + profiles + ); expect(result).toHaveLength(1); expect(result[0].id).toBe(1); @@ -491,8 +553,12 @@ describe('guideUtils', () => { }); it('should return earliest program start', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time)); - dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); + dateTimeUtils.initializeTime.mockImplementation((time) => + dayjs.utc(time) + ); + dateTimeUtils.isBefore.mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) + ); const programs = [ { start_time: '2024-01-15T12:00:00Z' }, @@ -501,7 +567,10 @@ describe('guideUtils', () => { ]; const defaultStart = dayjs.utc('2024-01-16T00:00:00Z'); - const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart); + const result = guideUtils.calculateEarliestProgramStart( + programs, + defaultStart + ); expect(result.hour()).toBe(10); }); @@ -517,8 +586,12 @@ describe('guideUtils', () => { }); it('should return latest program end', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => dayjs.utc(time)); - dateTimeUtils.isAfter.mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b))); + dateTimeUtils.initializeTime.mockImplementation((time) => + dayjs.utc(time) + ); + dateTimeUtils.isAfter.mockImplementation((a, b) => + dayjs(a).isAfter(dayjs(b)) + ); const programs = [ { end_time: '2024-01-15T12:00:00Z' }, @@ -638,8 +711,12 @@ describe('guideUtils', () => { it('should return "Today" for today', () => { const today = dayjs(); dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day')); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.startOfDay.mockImplementation((time) => + dayjs(time).startOf('day') + ); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.isSame.mockReturnValueOnce(true); const result = guideUtils.formatTime(today, 'MM/DD'); @@ -651,8 +728,12 @@ describe('guideUtils', () => { const today = dayjs(); const tomorrow = today.add(1, 'day'); dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day')); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.startOfDay.mockImplementation((time) => + dayjs(time).startOf('day') + ); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true); const result = guideUtils.formatTime(tomorrow, 'MM/DD'); @@ -664,8 +745,12 @@ describe('guideUtils', () => { const today = dayjs(); const future = today.add(3, 'day'); dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day')); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.startOfDay.mockImplementation((time) => + dayjs(time).startOf('day') + ); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.isSame.mockReturnValue(false); dateTimeUtils.isBefore.mockReturnValue(true); dateTimeUtils.format.mockReturnValue('Wednesday'); @@ -679,8 +764,12 @@ describe('guideUtils', () => { const today = dayjs(); const future = today.add(10, 'day'); dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day')); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.startOfDay.mockImplementation((time) => + dayjs(time).startOf('day') + ); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.isSame.mockReturnValue(false); dateTimeUtils.isBefore.mockReturnValue(false); dateTimeUtils.format.mockReturnValue('01/25'); @@ -695,13 +784,23 @@ describe('guideUtils', () => { it('should generate hours between start and end', () => { const start = dayjs('2024-01-15T10:00:00Z'); const end = dayjs('2024-01-15T13:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); - dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day')); + dateTimeUtils.isBefore.mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) + ); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); + dateTimeUtils.startOfDay.mockImplementation((time) => + dayjs(time).startOf('day') + ); dateTimeUtils.isSame.mockReturnValue(true); const formatDayLabel = vi.fn((time) => 'Today'); - const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); + const result = guideUtils.calculateHourTimeline( + start, + end, + formatDayLabel + ); expect(result).toHaveLength(3); expect(formatDayLabel).toHaveBeenCalledTimes(3); @@ -710,13 +809,25 @@ describe('guideUtils', () => { it('should mark new day transitions', () => { const start = dayjs('2024-01-15T23:00:00Z'); const end = dayjs('2024-01-16T02:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); - dateTimeUtils.startOfDay.mockImplementation((time) => dayjs(time).startOf('day')); - dateTimeUtils.isSame.mockImplementation((a, b, unit) => dayjs(a).isSame(dayjs(b), unit)); + dateTimeUtils.isBefore.mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) + ); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); + dateTimeUtils.startOfDay.mockImplementation((time) => + dayjs(time).startOf('day') + ); + dateTimeUtils.isSame.mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) + ); const formatDayLabel = vi.fn((time) => 'Day'); - const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); + const result = guideUtils.calculateHourTimeline( + start, + end, + formatDayLabel + ); expect(result[0].isNewDay).toBe(true); }); @@ -791,7 +902,11 @@ describe('guideUtils', () => { const channelIdByTvgId = new Map(); const channelById = new Map(); - const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + const result = guideUtils.matchChannelByTvgId( + channelIdByTvgId, + channelById, + 'tvg-1' + ); expect(result).toBeNull(); }); @@ -801,7 +916,11 @@ describe('guideUtils', () => { const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); const channelById = new Map([[1, channel]]); - const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + const result = guideUtils.matchChannelByTvgId( + channelIdByTvgId, + channelById, + 'tvg-1' + ); expect(result).toBe(channel); }); @@ -810,7 +929,11 @@ describe('guideUtils', () => { const channelIdByTvgId = new Map([['tvg-1', [999]]]); const channelById = new Map(); - const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + const result = guideUtils.matchChannelByTvgId( + channelIdByTvgId, + channelById, + 'tvg-1' + ); expect(result).toBeNull(); }); @@ -933,7 +1056,9 @@ describe('guideUtils', () => { start_time: '2024-01-15T10:30:00Z', }; const start = '2024-01-15T10:00:00Z'; - dateTimeUtils.convertToMs.mockImplementation((time) => dayjs(time).valueOf()); + dateTimeUtils.convertToMs.mockImplementation((time) => + dayjs(time).valueOf() + ); const result = guideUtils.calculateLeftScrollPosition(program, start); @@ -965,10 +1090,16 @@ describe('guideUtils', () => { }; const clickedTime = dayjs('2024-01-15T10:00:00Z'); const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.diff.mockReturnValue(60); - const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); + const result = guideUtils.calculateScrollPositionByTimeClick( + event, + clickedTime, + start + ); expect(result).toBeGreaterThanOrEqual(0); }); @@ -982,7 +1113,9 @@ describe('guideUtils', () => { }; const clickedTime = dayjs('2024-01-15T10:00:00Z'); const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.diff.mockReturnValue(75); guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); @@ -999,12 +1132,22 @@ describe('guideUtils', () => { }; const clickedTime = dayjs('2024-01-15T10:00:00Z'); const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => dayjs(time).add(amount, unit)); + dateTimeUtils.add.mockImplementation((time, amount, unit) => + dayjs(time).add(amount, unit) + ); dateTimeUtils.diff.mockReturnValue(120); - const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); + const result = guideUtils.calculateScrollPositionByTimeClick( + event, + clickedTime, + start + ); - expect(dateTimeUtils.add).toHaveBeenCalledWith(expect.anything(), 1, 'hour'); + expect(dateTimeUtils.add).toHaveBeenCalledWith( + expect.anything(), + 1, + 'hour' + ); }); }); @@ -1037,9 +1180,7 @@ describe('guideUtils', () => { 1: { id: 1, name: 'Sports' }, 2: { id: 2, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - ]; + const channels = [{ id: 1, channel_group_id: 1 }]; const result = guideUtils.getGroupOptions(channelGroups, channels); diff --git a/frontend/src/pages/guide.css b/frontend/src/pages/guide.css index 15ff6e0e..4c9216e2 100644 --- a/frontend/src/pages/guide.css +++ b/frontend/src/pages/guide.css @@ -7,22 +7,22 @@ white-space: nowrap; text-overflow: ellipsis; border-radius: 8px; - background: linear-gradient(to right, #2D2D2F, #1F1F20); + background: linear-gradient(to right, #2d2d2f, #1f1f20); /* Default background */ color: #fff; transition: all 0.2s ease-out; } .tv-guide .guide-program-container .guide-program.live { - background: linear-gradient(to right, #3BA882, #245043); + background: linear-gradient(to right, #3ba882, #245043); } .tv-guide .guide-program-container .guide-program.live:hover { - background: linear-gradient(to right, #2E9E80, #206E5E); + background: linear-gradient(to right, #2e9e80, #206e5e); } .tv-guide .guide-program-container .guide-program.not-live:hover { - background: linear-gradient(to right, #2F3F3A, #1E2926); + background: linear-gradient(to right, #2f3f3a, #1e2926); } /* New styles for expanded programs */ @@ -33,15 +33,15 @@ } .tv-guide .guide-program-container .guide-program.expanded.live { - background: linear-gradient(to right, #226F5D, #3BA882); + background: linear-gradient(to right, #226f5d, #3ba882); } .tv-guide .guide-program-container .guide-program.expanded.not-live { - background: linear-gradient(to right, #2C3F3A, #206E5E); + background: linear-gradient(to right, #2c3f3a, #206e5e); } .tv-guide .guide-program-container .guide-program.expanded.past { - background: linear-gradient(to right, #1F2423, #2F3A37); + background: linear-gradient(to right, #1f2423, #2f3a37); } /* Ensure channel logo is always on top */ diff --git a/frontend/src/pages/guideUtils.js b/frontend/src/pages/guideUtils.js index 68bb74b2..98dc8e35 100644 --- a/frontend/src/pages/guideUtils.js +++ b/frontend/src/pages/guideUtils.js @@ -10,7 +10,7 @@ import { format, getNow, getNowMs, - roundToNearest + roundToNearest, } from '../utils/dateTimeUtils.js'; import API from '../api.js'; @@ -28,7 +28,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) { const tvgRecord = channel.epg_data_id ? tvgsById[channel.epg_data_id] : null; - + // For dummy EPG sources, ALWAYS use channel UUID to ensure unique programs per channel // This prevents multiple channels with the same dummy EPG from showing identical data let tvgId; @@ -45,7 +45,7 @@ export function buildChannelIdMap(channels, tvgsById, epgs = {}) { // No EPG data: use channel UUID tvgId = channel.uuid; } - + if (tvgId) { const tvgKey = String(tvgId); if (!map.has(tvgKey)) { @@ -138,19 +138,25 @@ export const fetchPrograms = async () => { export const sortChannels = (channels) => { // Include ALL channels, sorted by channel number - don't filter by EPG data const sortedChannels = Object.values(channels).sort( - (a, b) => - (a.channel_number || Infinity) - (b.channel_number || Infinity) + (a, b) => (a.channel_number || Infinity) - (b.channel_number || Infinity) ); console.log(`Using all ${sortedChannels.length} available channels`); return sortedChannels; -} +}; -export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, selectedProfileId, profiles) => { +export const filterGuideChannels = ( + guideChannels, + searchQuery, + selectedGroupId, + selectedProfileId, + profiles +) => { return guideChannels.filter((channel) => { // Search filter if (searchQuery) { - if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (!channel.name.toLowerCase().includes(searchQuery.toLowerCase())) + return false; } // Channel group filter @@ -162,17 +168,17 @@ export const filterGuideChannels = (guideChannels, searchQuery, selectedGroupId, if (selectedProfileId !== 'all') { const profileChannels = profiles[selectedProfileId]?.channels || []; const enabledChannelIds = Array.isArray(profileChannels) - ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id) - : profiles[selectedProfileId]?.channels instanceof Set - ? Array.from(profiles[selectedProfileId].channels) - : []; + ? profileChannels.filter((pc) => pc.enabled).map((pc) => pc.id) + : profiles[selectedProfileId]?.channels instanceof Set + ? Array.from(profiles[selectedProfileId].channels) + : []; if (!enabledChannelIds.includes(channel.id)) return false; } return true; }); -} +}; export const calculateEarliestProgramStart = (programs, defaultStart) => { if (!programs.length) return defaultStart; @@ -180,7 +186,7 @@ export const calculateEarliestProgramStart = (programs, defaultStart) => { const s = initializeTime(p.start_time); return isBefore(s, acc) ? s : acc; }, defaultStart); -} +}; export const calculateLatestProgramEnd = (programs, defaultEnd) => { if (!programs.length) return defaultEnd; @@ -188,17 +194,17 @@ export const calculateLatestProgramEnd = (programs, defaultEnd) => { const e = initializeTime(p.end_time); return isAfter(e, acc) ? e : acc; }, defaultEnd); -} +}; export const calculateStart = (earliestProgramStart, defaultStart) => { return isBefore(earliestProgramStart, defaultStart) ? earliestProgramStart : defaultStart; -} +}; export const calculateEnd = (latestProgramEnd, defaultEnd) => { return isAfter(latestProgramEnd, defaultEnd) ? latestProgramEnd : defaultEnd; -} +}; export const mapChannelsById = (guideChannels) => { const map = new Map(); @@ -206,7 +212,7 @@ export const mapChannelsById = (guideChannels) => { map.set(channel.id, channel); }); return map; -} +}; export const mapRecordingsByProgramId = (recordings) => { const map = new Map(); @@ -217,7 +223,7 @@ export const mapRecordingsByProgramId = (recordings) => { } }); return map; -} +}; export const formatTime = (time, dateFormat) => { const today = startOfDay(getNow()); @@ -236,7 +242,7 @@ export const formatTime = (time, dateFormat) => { // Beyond a week, show month and day return format(time, dateFormat); } -} +}; export const calculateHourTimeline = (start, end, formatDayLabel) => { const hours = []; @@ -262,7 +268,7 @@ export const calculateHourTimeline = (start, end, formatDayLabel) => { current = add(current, 1, 'hour'); } return hours; -} +}; export const calculateNowPosition = (now, start, end) => { if (isBefore(now, start) || isAfter(now, end)) return -1; @@ -286,11 +292,11 @@ export const matchChannelByTvgId = (channelIdByTvgId, channelById, tvgId) => { } // Return the first channel that matches this TVG ID return channelById.get(channelIds[0]) || null; -} +}; export const fetchRules = async () => { return await API.listSeriesRules(); -} +}; export const getRuleByProgram = (rules, program) => { return (rules || []).find( @@ -298,7 +304,7 @@ export const getRuleByProgram = (rules, program) => { String(r.tvg_id) === String(program.tvg_id) && (!r.title || r.title === program.title) ); -} +}; export const createRecording = async (channel, program) => { await API.createRecording({ @@ -307,7 +313,7 @@ export const createRecording = async (channel, program) => { end_time: program.end_time, custom_properties: { program }, }); -} +}; export const createSeriesRule = async (program, mode) => { await API.createSeriesRule({ @@ -315,15 +321,14 @@ export const createSeriesRule = async (program, mode) => { mode, title: program.title, }); -} +}; export const evaluateSeriesRule = async (program) => { await API.evaluateSeriesRules(program.tvg_id); -} +}; export const calculateLeftScrollPosition = (program, start) => { - const programStartMs = - program.startMs ?? convertToMs(program.start_time); + const programStartMs = program.startMs ?? convertToMs(program.start_time); const startOffsetMinutes = (programStartMs - convertToMs(start)) / 60000; return (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; @@ -331,9 +336,13 @@ export const calculateLeftScrollPosition = (program, start) => { export const calculateDesiredScrollPosition = (leftPx) => { return Math.max(0, leftPx - 20); -} +}; -export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => { +export const calculateScrollPositionByTimeClick = ( + event, + clickedTime, + start +) => { const rect = event.currentTarget.getBoundingClientRect(); const clickPositionX = event.clientX - rect.left; const percentageAcross = clickPositionX / rect.width; @@ -341,9 +350,10 @@ export const calculateScrollPositionByTimeClick = (event, clickedTime, start) => const snappedMinute = Math.round(minuteWithinHour / 15) * 15; - const adjustedTime = (snappedMinute === 60) - ? add(clickedTime, 1, 'hour').minute(0) - : clickedTime.minute(snappedMinute); + const adjustedTime = + snappedMinute === 60 + ? add(clickedTime, 1, 'hour').minute(0) + : clickedTime.minute(snappedMinute); const snappedOffset = diff(adjustedTime, start, 'minute'); return (snappedOffset / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH; @@ -372,7 +382,7 @@ export const getGroupOptions = (channelGroups, guideChannels) => { }); } return options; -} +}; export const getProfileOptions = (profiles) => { const options = [{ value: 'all', label: 'All Profiles' }]; @@ -390,12 +400,12 @@ export const getProfileOptions = (profiles) => { } return options; -} +}; export const deleteSeriesRuleByTvgId = async (tvg_id) => { await API.deleteSeriesRule(tvg_id); -} +}; export const evaluateSeriesRulesByTvgId = async (tvg_id) => { await API.evaluateSeriesRules(tvg_id); -} \ No newline at end of file +}; diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx index 7dc8277b..6ec23873 100644 --- a/frontend/src/store/__tests__/auth.test.jsx +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -54,36 +54,50 @@ describe('useAuthStore', () => { localStorageMock.clear(); // Setup default store mocks - useSettingsStore.mockImplementation((selector) => selector({ - fetchSettings: vi.fn().mockResolvedValue(), - })); + useSettingsStore.mockImplementation((selector) => + selector({ + fetchSettings: vi.fn().mockResolvedValue(), + }) + ); - useChannelsStore.mockImplementation((selector) => selector({ - fetchChannels: vi.fn().mockResolvedValue(), - fetchChannelGroups: vi.fn().mockResolvedValue(), - fetchChannelProfiles: vi.fn().mockResolvedValue(), - })); + useChannelsStore.mockImplementation((selector) => + selector({ + fetchChannels: vi.fn().mockResolvedValue(), + fetchChannelGroups: vi.fn().mockResolvedValue(), + fetchChannelProfiles: vi.fn().mockResolvedValue(), + }) + ); - usePlaylistsStore.mockImplementation((selector) => selector({ - fetchPlaylists: vi.fn().mockResolvedValue(), - })); + usePlaylistsStore.mockImplementation((selector) => + selector({ + fetchPlaylists: vi.fn().mockResolvedValue(), + }) + ); - useEPGsStore.mockImplementation((selector) => selector({ - fetchEPGs: vi.fn().mockResolvedValue(), - fetchEPGData: vi.fn().mockResolvedValue(), - })); + useEPGsStore.mockImplementation((selector) => + selector({ + fetchEPGs: vi.fn().mockResolvedValue(), + fetchEPGData: vi.fn().mockResolvedValue(), + }) + ); - useStreamProfilesStore.mockImplementation((selector) => selector({ - fetchProfiles: vi.fn().mockResolvedValue(), - })); + useStreamProfilesStore.mockImplementation((selector) => + selector({ + fetchProfiles: vi.fn().mockResolvedValue(), + }) + ); - useUserAgentsStore.mockImplementation((selector) => selector({ - fetchUserAgents: vi.fn().mockResolvedValue(), - })); + useUserAgentsStore.mockImplementation((selector) => + selector({ + fetchUserAgents: vi.fn().mockResolvedValue(), + }) + ); - useUsersStore.mockImplementation((selector) => selector({ - fetchUsers: vi.fn().mockResolvedValue(), - })); + useUsersStore.mockImplementation((selector) => + selector({ + fetchUsers: vi.fn().mockResolvedValue(), + }) + ); }); afterEach(() => { @@ -140,13 +154,25 @@ describe('useAuthStore', () => { const { result } = renderHook(() => useAuthStore()); await act(async () => { - await result.current.login({ username: 'testuser', password: 'password' }); + await result.current.login({ + username: 'testuser', + password: 'password', + }); }); expect(API.login).toHaveBeenCalledWith('testuser', 'password'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockAccessToken); - expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', mockRefreshToken); - expect(localStorageMock.setItem).toHaveBeenCalledWith('tokenExpiration', expect.any(Number)); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'accessToken', + mockAccessToken + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'refreshToken', + mockRefreshToken + ); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'tokenExpiration', + expect.any(Number) + ); }); it('should handle login failure', async () => { @@ -182,7 +208,10 @@ describe('useAuthStore', () => { expect(API.refreshToken).toHaveBeenCalledWith('old-refresh-token'); expect(newToken).toBe(mockNewAccessToken); - expect(localStorageMock.setItem).toHaveBeenCalledWith('accessToken', mockNewAccessToken); + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'accessToken', + mockNewAccessToken + ); }); it('should return false if no refresh token exists', async () => { @@ -213,7 +242,9 @@ describe('useAuthStore', () => { expect(result.current.isAuthenticated).toBe(false); expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + 'tokenExpiration' + ); }); }); @@ -277,7 +308,9 @@ describe('useAuthStore', () => { expect(result.current.user).toBeNull(); expect(localStorageMock.removeItem).toHaveBeenCalledWith('accessToken'); expect(localStorageMock.removeItem).toHaveBeenCalledWith('refreshToken'); - expect(localStorageMock.removeItem).toHaveBeenCalledWith('tokenExpiration'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith( + 'tokenExpiration' + ); }); it('should continue logout even if API call fails', async () => { @@ -372,7 +405,6 @@ describe('useAuthStore', () => { expect(fetchUsers).toHaveBeenCalled(); }); - it('should not fetch users for non-admin user', async () => { const mockUser = { username: 'reseller', @@ -418,7 +450,9 @@ describe('useAuthStore', () => { API.me.mockResolvedValue(mockUser); - const fetchChannels = vi.fn().mockRejectedValue(new Error('Fetch failed')); + const fetchChannels = vi + .fn() + .mockRejectedValue(new Error('Fetch failed')); useChannelsStore.getState = vi.fn(() => ({ fetchChannels, @@ -437,7 +471,11 @@ describe('useAuthStore', () => { describe('setUser', () => { it('should update user state', () => { const { result } = renderHook(() => useAuthStore()); - const newUser = { username: 'test', email: 'test@test.com', user_level: USER_LEVELS.ADMIN }; + const newUser = { + username: 'test', + email: 'test@test.com', + user_level: USER_LEVELS.ADMIN, + }; act(() => { result.current.setUser(newUser); @@ -494,7 +532,10 @@ describe('useAuthStore', () => { const { result } = renderHook(() => useAuthStore()); await act(async () => { - await result.current.login({ username: 'testuser', password: 'password' }); + await result.current.login({ + username: 'testuser', + password: 'password', + }); }); expect(result.current.accessToken).toBeNull(); @@ -577,7 +618,7 @@ describe('useAuthStore', () => { // Reset state before the test useAuthStore.setState({ isInitializing: false, - isInitialized: false + isInitialized: false, }); API.me.mockRejectedValue(new Error('API error')); @@ -620,7 +661,7 @@ describe('useAuthStore', () => { // Wait for the background call to complete await act(async () => { - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); }); // The background fetchChannels is called synchronously without await diff --git a/frontend/src/store/__tests__/channels.test.jsx b/frontend/src/store/__tests__/channels.test.jsx index 6b452549..697f74ba 100644 --- a/frontend/src/store/__tests__/channels.test.jsx +++ b/frontend/src/store/__tests__/channels.test.jsx @@ -243,7 +243,11 @@ describe('useChannelsStore', () => { const { result } = renderHook(() => useChannelsStore()); act(() => { - result.current.updateProfile({ id: '1', name: 'Updated', channels: [3] }); + result.current.updateProfile({ + id: '1', + name: 'Updated', + channels: [3], + }); }); expect(result.current.profiles['1'].name).toBe('Updated'); @@ -254,7 +258,7 @@ describe('useChannelsStore', () => { act(() => { useChannelsStore.setState({ - profiles: { '1': { id: '1' }, '2': { id: '2' } }, + profiles: { 1: { id: '1' }, 2: { id: '2' } }, selectedProfileId: '1', }); }); @@ -274,7 +278,7 @@ describe('useChannelsStore', () => { act(() => { useChannelsStore.setState({ - profiles: { '1': { id: '1', channels: new Set([1]) } }, + profiles: { 1: { id: '1', channels: new Set([1]) } }, }); }); @@ -291,7 +295,7 @@ describe('useChannelsStore', () => { act(() => { useChannelsStore.setState({ - profiles: { '1': { id: '1', channels: new Set([1, 2, 3]) } }, + profiles: { 1: { id: '1', channels: new Set([1, 2, 3]) } }, }); }); @@ -316,9 +320,7 @@ describe('useChannelsStore', () => { }); const newStats = { - channels: [ - { channel_id: 'uuid-1', clients: [] }, - ], + channels: [{ channel_id: 'uuid-1', clients: [] }], }; act(() => { diff --git a/frontend/src/store/__tests__/channelsTable.test.jsx b/frontend/src/store/__tests__/channelsTable.test.jsx index 0bf3e31a..0b98e9f6 100644 --- a/frontend/src/store/__tests__/channelsTable.test.jsx +++ b/frontend/src/store/__tests__/channelsTable.test.jsx @@ -40,7 +40,9 @@ describe('useChannelsTableStore', () => { expect(result.current.channels).toEqual([]); expect(result.current.pageCount).toBe(0); expect(result.current.totalCount).toBe(0); - expect(result.current.sorting).toEqual([{ id: 'channel_number', desc: false }]); + expect(result.current.sorting).toEqual([ + { id: 'channel_number', desc: false }, + ]); expect(result.current.pagination.pageIndex).toBe(0); expect(result.current.pagination.pageSize).toBe(50); expect(result.current.selectedChannelIds).toEqual([]); @@ -187,9 +189,7 @@ describe('useChannelsTableStore', () => { it('should return empty array for channel without streams', () => { const { result } = renderHook(() => useChannelsTableStore()); - const mockChannels = [ - { id: 1, name: 'Channel 1' }, - ]; + const mockChannels = [{ id: 1, name: 'Channel 1' }]; act(() => { useChannelsTableStore.setState({ channels: mockChannels }); @@ -203,9 +203,7 @@ describe('useChannelsTableStore', () => { it('should return empty array for non-existent channel', () => { const { result } = renderHook(() => useChannelsTableStore()); - const mockChannels = [ - { id: 1, name: 'Channel 1', streams: ['stream1'] }, - ]; + const mockChannels = [{ id: 1, name: 'Channel 1', streams: ['stream1'] }]; act(() => { useChannelsTableStore.setState({ channels: mockChannels }); @@ -343,7 +341,11 @@ describe('useChannelsTableStore', () => { useChannelsTableStore.setState({ channels: mockChannels }); }); - const updatedChannel = { id: 2, name: 'Updated Channel 2', channel_number: 22 }; + const updatedChannel = { + id: 2, + name: 'Updated Channel 2', + channel_number: 22, + }; act(() => { result.current.updateChannel(updatedChannel); @@ -368,7 +370,11 @@ describe('useChannelsTableStore', () => { useChannelsTableStore.setState({ channels: mockChannels }); }); - const updatedChannel = { id: 999, name: 'Non-existent', channel_number: 999 }; + const updatedChannel = { + id: 999, + name: 'Non-existent', + channel_number: 999, + }; act(() => { result.current.updateChannel(updatedChannel); @@ -389,7 +395,11 @@ describe('useChannelsTableStore', () => { useChannelsTableStore.setState({ channels: mockChannels }); }); - const updatedChannel = { id: 1, name: 'Updated Channel 1', channel_number: 10 }; + const updatedChannel = { + id: 1, + name: 'Updated Channel 1', + channel_number: 10, + }; act(() => { result.current.updateChannel(updatedChannel); diff --git a/frontend/src/store/__tests__/epgs.test.jsx b/frontend/src/store/__tests__/epgs.test.jsx index 05f136d3..dfed7edb 100644 --- a/frontend/src/store/__tests__/epgs.test.jsx +++ b/frontend/src/store/__tests__/epgs.test.jsx @@ -65,7 +65,9 @@ describe('useEPGsStore', () => { }); it('should set loading state while fetching', async () => { - api.getEPGs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100))); + api.getEPGs.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 100)) + ); const { result } = renderHook(() => useEPGsStore()); @@ -87,7 +89,9 @@ describe('useEPGsStore', () => { const mockError = new Error('Network error'); api.getEPGs.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); await act(async () => { @@ -96,7 +100,10 @@ describe('useEPGsStore', () => { expect(result.current.error).toBe('Failed to load epgs.'); expect(result.current.isLoading).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch epgs:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch epgs:', + mockError + ); consoleSpy.mockRestore(); }); @@ -142,7 +149,9 @@ describe('useEPGsStore', () => { }); it('should set loading state while fetching', async () => { - api.getEPGData.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve([]), 100))); + api.getEPGData.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 100)) + ); const { result } = renderHook(() => useEPGsStore()); @@ -163,7 +172,9 @@ describe('useEPGsStore', () => { const mockError = new Error('API error'); api.getEPGData.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); await act(async () => { @@ -173,7 +184,10 @@ describe('useEPGsStore', () => { expect(result.current.error).toBe('Failed to load tvgs.'); expect(result.current.tvgsLoaded).toBe(true); expect(result.current.isLoading).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch tvgs:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch tvgs:', + mockError + ); consoleSpy.mockRestore(); }); @@ -274,7 +288,9 @@ describe('useEPGsStore', () => { }); it('should not update state when called with invalid epg (null)', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; @@ -287,13 +303,18 @@ describe('useEPGsStore', () => { }); expect(result.current.epgs).toEqual(initialEPGs); - expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', null); + expect(consoleSpy).toHaveBeenCalledWith( + 'updateEPG called with invalid epg:', + null + ); consoleSpy.mockRestore(); }); it('should not update state when called with invalid epg (missing id)', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; @@ -308,13 +329,18 @@ describe('useEPGsStore', () => { }); expect(result.current.epgs).toEqual(initialEPGs); - expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', invalidEPG); + expect(consoleSpy).toHaveBeenCalledWith( + 'updateEPG called with invalid epg:', + invalidEPG + ); consoleSpy.mockRestore(); }); it('should not update state when called with invalid epg (non-object)', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); const initialEPGs = { epg1: { id: 'epg1', name: 'Test' } }; @@ -327,7 +353,10 @@ describe('useEPGsStore', () => { }); expect(result.current.epgs).toEqual(initialEPGs); - expect(consoleSpy).toHaveBeenCalledWith('updateEPG called with invalid epg:', 'invalid'); + expect(consoleSpy).toHaveBeenCalledWith( + 'updateEPG called with invalid epg:', + 'invalid' + ); consoleSpy.mockRestore(); }); @@ -518,7 +547,9 @@ describe('useEPGsStore', () => { }); }); - expect(result.current.epgs.source1.last_message).toBe('Connection failed'); + expect(result.current.epgs.source1.last_message).toBe( + 'Connection failed' + ); }); it('should use default error message if error is not provided', () => { @@ -535,7 +566,9 @@ describe('useEPGsStore', () => { }); it('should not update state when called with invalid data (null)', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); const initialEPGs = { ...result.current.epgs }; @@ -547,13 +580,18 @@ describe('useEPGsStore', () => { expect(result.current.epgs).toEqual(initialEPGs); expect(result.current.refreshProgress).toEqual(initialProgress); - expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', null); + expect(consoleSpy).toHaveBeenCalledWith( + 'updateEPGProgress called with invalid data:', + null + ); consoleSpy.mockRestore(); }); it('should not update state when called with invalid data (missing source)', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useEPGsStore()); const initialEPGs = { ...result.current.epgs }; @@ -565,7 +603,10 @@ describe('useEPGsStore', () => { expect(result.current.epgs).toEqual(initialEPGs); expect(result.current.refreshProgress).toEqual(initialProgress); - expect(consoleSpy).toHaveBeenCalledWith('updateEPGProgress called with invalid data:', { progress: 50 }); + expect(consoleSpy).toHaveBeenCalledWith( + 'updateEPGProgress called with invalid data:', + { progress: 50 } + ); consoleSpy.mockRestore(); }); @@ -667,7 +708,11 @@ describe('useEPGsStore', () => { act(() => { useEPGsStore.setState({ epgs: { - source1: { id: 'source1', status: 'parsing', last_message: 'Processing' }, + source1: { + id: 'source1', + status: 'parsing', + last_message: 'Processing', + }, }, }); }); diff --git a/frontend/src/store/__tests__/logos.test.jsx b/frontend/src/store/__tests__/logos.test.jsx index 59ac4283..fff82a95 100644 --- a/frontend/src/store/__tests__/logos.test.jsx +++ b/frontend/src/store/__tests__/logos.test.jsx @@ -58,7 +58,11 @@ describe('useLogosStore', () => { it('should add logo to main logos store', () => { const { result } = renderHook(() => useLogosStore()); - const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + const newLogo = { + id: 'logo1', + name: 'Logo 1', + url: 'http://example.com/logo1.png', + }; act(() => { result.current.addLogo(newLogo); @@ -76,7 +80,11 @@ describe('useLogosStore', () => { useLogosStore.setState({ hasLoadedChannelLogos: true }); }); - const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + const newLogo = { + id: 'logo1', + name: 'Logo 1', + url: 'http://example.com/logo1.png', + }; act(() => { result.current.addLogo(newLogo); @@ -89,7 +97,11 @@ describe('useLogosStore', () => { it('should not add logo to channelLogos if hasLoadedChannelLogos is false', () => { const { result } = renderHook(() => useLogosStore()); - const newLogo = { id: 'logo1', name: 'Logo 1', url: 'http://example.com/logo1.png' }; + const newLogo = { + id: 'logo1', + name: 'Logo 1', + url: 'http://example.com/logo1.png', + }; act(() => { result.current.addLogo(newLogo); @@ -104,8 +116,16 @@ describe('useLogosStore', () => { it('should update logo in main logos store', () => { const { result } = renderHook(() => useLogosStore()); - const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; - const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + const originalLogo = { + id: 'logo1', + name: 'Original', + url: 'http://example.com/original.png', + }; + const updatedLogo = { + id: 'logo1', + name: 'Updated', + url: 'http://example.com/updated.png', + }; act(() => { useLogosStore.setState({ logos: { logo1: originalLogo } }); @@ -121,8 +141,16 @@ describe('useLogosStore', () => { it('should update logo in channelLogos if it exists there', () => { const { result } = renderHook(() => useLogosStore()); - const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; - const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + const originalLogo = { + id: 'logo1', + name: 'Original', + url: 'http://example.com/original.png', + }; + const updatedLogo = { + id: 'logo1', + name: 'Updated', + url: 'http://example.com/updated.png', + }; act(() => { useLogosStore.setState({ @@ -142,8 +170,16 @@ describe('useLogosStore', () => { it('should not update channelLogos if logo does not exist there', () => { const { result } = renderHook(() => useLogosStore()); - const originalLogo = { id: 'logo1', name: 'Original', url: 'http://example.com/original.png' }; - const updatedLogo = { id: 'logo1', name: 'Updated', url: 'http://example.com/updated.png' }; + const originalLogo = { + id: 'logo1', + name: 'Original', + url: 'http://example.com/original.png', + }; + const updatedLogo = { + id: 'logo1', + name: 'Updated', + url: 'http://example.com/updated.png', + }; act(() => { useLogosStore.setState({ logos: { logo1: originalLogo } }); @@ -249,7 +285,9 @@ describe('useLogosStore', () => { const mockError = new Error('Network error'); api.getLogos.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); await expect( @@ -261,7 +299,10 @@ describe('useLogosStore', () => { await waitFor(() => { expect(result.current.error).toBe('Failed to load logos.'); expect(result.current.isLoading).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch logos:', + mockError + ); }); consoleSpy.mockRestore(); @@ -354,7 +395,9 @@ describe('useLogosStore', () => { const mockError = new Error('API error'); api.getLogos.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); await expect( @@ -366,7 +409,10 @@ describe('useLogosStore', () => { await waitFor(() => { expect(result.current.error).toBe('Failed to load all logos.'); expect(result.current.isLoading).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch all logos:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch all logos:', + mockError + ); }); consoleSpy.mockRestore(); @@ -396,7 +442,10 @@ describe('useLogosStore', () => { logo2: { id: 'logo2', name: 'Used Logo 2' }, }); expect(result.current.isLoading).toBe(false); - expect(api.getLogos).toHaveBeenCalledWith({ used: 'true', page_size: 100 }); + expect(api.getLogos).toHaveBeenCalledWith({ + used: 'true', + page_size: 100, + }); expect(response).toEqual(mockResponse); }); @@ -426,7 +475,9 @@ describe('useLogosStore', () => { const mockError = new Error('Fetch error'); api.getLogos.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); await expect( @@ -437,7 +488,10 @@ describe('useLogosStore', () => { await waitFor(() => { expect(result.current.error).toBe('Failed to load used logos.'); - expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch used logos:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch used logos:', + mockError + ); }); consoleSpy.mockRestore(); @@ -537,7 +591,9 @@ describe('useLogosStore', () => { const mockError = new Error('Fetch error'); api.getLogosByIds.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); await expect( @@ -546,7 +602,10 @@ describe('useLogosStore', () => { }) ).rejects.toThrow('Fetch error'); - expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch logos by IDs:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch logos by IDs:', + mockError + ); consoleSpy.mockRestore(); }); @@ -565,9 +624,7 @@ describe('useLogosStore', () => { next: null, }; - api.getLogos - .mockResolvedValueOnce(page1) - .mockResolvedValueOnce(page2); + api.getLogos.mockResolvedValueOnce(page1).mockResolvedValueOnce(page2); const { result } = renderHook(() => useLogosStore()); @@ -589,7 +646,9 @@ describe('useLogosStore', () => { const mockError = new Error('Network error'); api.getLogos.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); await act(async () => { @@ -597,7 +656,10 @@ describe('useLogosStore', () => { }); expect(result.current.backgroundLoading).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith('Background logo loading failed:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Background logo loading failed:', + mockError + ); consoleSpy.mockRestore(); }); @@ -665,7 +727,9 @@ describe('useLogosStore', () => { const mockError = new Error('Fetch error'); api.getLogos.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); result.current.backgroundLoadAllLogos(); @@ -675,7 +739,10 @@ describe('useLogosStore', () => { }); expect(result.current.backgroundLoading).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith('Background all logos loading failed:', mockError); + expect(consoleSpy).toHaveBeenCalledWith( + 'Background all logos loading failed:', + mockError + ); consoleSpy.mockRestore(); @@ -713,7 +780,10 @@ describe('useLogosStore', () => { }); it('should not start if channelLogos already has many items', async () => { - const channelLogos = Array.from({ length: 150 }, (_, i) => [`logo${i}`, { id: `logo${i}` }]); + const channelLogos = Array.from({ length: 150 }, (_, i) => [ + `logo${i}`, + { id: `logo${i}` }, + ]); const channelLogosObj = Object.fromEntries(channelLogos); const { result } = renderHook(() => useLogosStore()); @@ -747,8 +817,12 @@ describe('useLogosStore', () => { expect(result.current.hasLoadedChannelLogos).toBe(true); expect(result.current.backgroundLoading).toBe(false); expect(Object.keys(result.current.channelLogos).length).toBe(2); - expect(consoleSpy).toHaveBeenCalledWith('Background loading channel logos...'); - expect(consoleSpy).toHaveBeenCalledWith('Background loaded 2 channel logos'); + expect(consoleSpy).toHaveBeenCalledWith( + 'Background loading channel logos...' + ); + expect(consoleSpy).toHaveBeenCalledWith( + 'Background loaded 2 channel logos' + ); consoleSpy.mockRestore(); }); @@ -757,8 +831,12 @@ describe('useLogosStore', () => { const mockError = new Error('Fetch error'); api.getLogos.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const consoleLogSpy = vi + .spyOn(console, 'log') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); await act(async () => { @@ -766,7 +844,10 @@ describe('useLogosStore', () => { }); expect(result.current.backgroundLoading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Background channel logo loading failed:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Background channel logo loading failed:', + mockError + ); consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); @@ -801,7 +882,9 @@ describe('useLogosStore', () => { const mockError = new Error('Background error'); api.getLogos.mockRejectedValue(mockError); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useLogosStore()); result.current.startBackgroundLoading(); diff --git a/frontend/src/store/__tests__/playlists.test.jsx b/frontend/src/store/__tests__/playlists.test.jsx index 2d49791e..da653259 100644 --- a/frontend/src/store/__tests__/playlists.test.jsx +++ b/frontend/src/store/__tests__/playlists.test.jsx @@ -65,13 +65,17 @@ describe('usePlaylistsStore', () => { expect(api.getPlaylist).toHaveBeenCalledWith('playlist1'); expect(result.current.playlists).toEqual([mockPlaylist]); - expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] }); + expect(result.current.profiles).toEqual({ + playlist1: ['profile1', 'profile2'], + }); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBe(null); }); it('should handle fetch playlist error', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); api.getPlaylist.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => usePlaylistsStore()); @@ -110,7 +114,9 @@ describe('usePlaylistsStore', () => { }); it('should handle fetch playlists error', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); api.getPlaylists.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => usePlaylistsStore()); @@ -142,8 +148,16 @@ describe('usePlaylistsStore', () => { it('should update playlist', () => { const { result } = renderHook(() => usePlaylistsStore()); - const existingPlaylist = { id: 'playlist1', name: 'Old Name', profiles: ['profile1'] }; - const updatedPlaylist = { id: 'playlist1', name: 'New Name', profiles: ['profile1', 'profile2'] }; + const existingPlaylist = { + id: 'playlist1', + name: 'Old Name', + profiles: ['profile1'], + }; + const updatedPlaylist = { + id: 'playlist1', + name: 'New Name', + profiles: ['profile1', 'profile2'], + }; act(() => { result.current.playlists = [existingPlaylist]; @@ -155,7 +169,9 @@ describe('usePlaylistsStore', () => { }); expect(result.current.playlists).toEqual([updatedPlaylist]); - expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2'] }); + expect(result.current.profiles).toEqual({ + playlist1: ['profile1', 'profile2'], + }); }); it('should update profiles', () => { @@ -166,10 +182,16 @@ describe('usePlaylistsStore', () => { }); act(() => { - result.current.updateProfiles('playlist1', ['profile1', 'profile2', 'profile3']); + result.current.updateProfiles('playlist1', [ + 'profile1', + 'profile2', + 'profile3', + ]); }); - expect(result.current.profiles).toEqual({ playlist1: ['profile1', 'profile2', 'profile3'] }); + expect(result.current.profiles).toEqual({ + playlist1: ['profile1', 'profile2', 'profile3'], + }); }); it('should remove playlists', () => { @@ -187,7 +209,9 @@ describe('usePlaylistsStore', () => { result.current.removePlaylists(['playlist1', 'playlist3']); }); - expect(result.current.playlists).toEqual([{ id: 'playlist2', name: 'Playlist 2' }]); + expect(result.current.playlists).toEqual([ + { id: 'playlist2', name: 'Playlist 2' }, + ]); }); it('should set refresh progress with two parameters', () => { @@ -228,7 +252,11 @@ describe('usePlaylistsStore', () => { expect(result.current.refreshProgress.account1.action).toBe('initializing'); act(() => { - result.current.setRefreshProgress({ account: 'account1', action: 'refreshing', progress: 25 }); + result.current.setRefreshProgress({ + account: 'account1', + action: 'refreshing', + progress: 25, + }); }); expect(result.current.refreshProgress.account1.action).toBe('refreshing'); diff --git a/frontend/src/store/__tests__/plugins.test.jsx b/frontend/src/store/__tests__/plugins.test.jsx index 2db48095..b47d3450 100644 --- a/frontend/src/store/__tests__/plugins.test.jsx +++ b/frontend/src/store/__tests__/plugins.test.jsx @@ -136,7 +136,11 @@ describe('usePluginStore', () => { result.current.updatePlugin('plugin1', { name: 'Updated Plugin' }); }); - expect(result.current.plugins[1]).toEqual({ key: 'plugin2', name: 'Plugin 2', enabled: false }); + expect(result.current.plugins[1]).toEqual({ + key: 'plugin2', + name: 'Plugin 2', + enabled: false, + }); }); it('should add plugin', () => { @@ -152,7 +156,11 @@ describe('usePluginStore', () => { it('should add plugin to existing plugins', () => { const { result } = renderHook(() => usePluginStore()); - const existingPlugin = { key: 'plugin1', name: 'Existing Plugin', enabled: true }; + const existingPlugin = { + key: 'plugin1', + name: 'Existing Plugin', + enabled: true, + }; const newPlugin = { key: 'plugin2', name: 'New Plugin', enabled: false }; act(() => { @@ -192,9 +200,7 @@ describe('usePluginStore', () => { act(() => { usePluginStore.setState({ - plugins: [ - { key: 'plugin1', name: 'Plugin 1', enabled: true }, - ], + plugins: [{ key: 'plugin1', name: 'Plugin 1', enabled: true }], }); }); @@ -208,9 +214,7 @@ describe('usePluginStore', () => { }); it('should invalidate plugins and refetch', async () => { - const mockPlugins = [ - { key: 'plugin1', name: 'Plugin 1', enabled: true }, - ]; + const mockPlugins = [{ key: 'plugin1', name: 'Plugin 1', enabled: true }]; API.getPlugins.mockResolvedValue(mockPlugins); @@ -218,9 +222,7 @@ describe('usePluginStore', () => { act(() => { usePluginStore.setState({ - plugins: [ - { key: 'old-plugin', name: 'Old Plugin', enabled: false }, - ], + plugins: [{ key: 'old-plugin', name: 'Old Plugin', enabled: false }], }); }); diff --git a/frontend/src/store/__tests__/settings.test.jsx b/frontend/src/store/__tests__/settings.test.jsx index 58f64724..016fce07 100644 --- a/frontend/src/store/__tests__/settings.test.jsx +++ b/frontend/src/store/__tests__/settings.test.jsx @@ -190,7 +190,10 @@ describe('useSettingsStore', () => { result.current.updateSetting({ key: 'setting1', value: 'updated' }); }); - expect(result.current.settings.setting2).toEqual({ key: 'setting2', value: 'value2' }); + expect(result.current.settings.setting2).toEqual({ + key: 'setting2', + value: 'value2', + }); }); it('should handle empty settings array', async () => { @@ -249,7 +252,10 @@ describe('useSettingsStore', () => { }, }); - api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + api.getVersion.mockResolvedValue({ + version: '2.0.0', + timestamp: '2024-01-01T00:00:00Z', + }); const { result } = renderHook(() => useSettingsStore()); @@ -336,7 +342,10 @@ describe('useSettingsStore', () => { api.getSettings.mockResolvedValue(mockSettings); api.getEnvironmentSettings.mockResolvedValue(mockEnv); - api.getVersion.mockResolvedValue({ version: '2.0.0', timestamp: '2024-01-01T00:00:00Z' }); + api.getVersion.mockResolvedValue({ + version: '2.0.0', + timestamp: '2024-01-01T00:00:00Z', + }); const { result } = renderHook(() => useSettingsStore()); diff --git a/frontend/src/store/__tests__/streamProfiles.test.jsx b/frontend/src/store/__tests__/streamProfiles.test.jsx index 30380664..72f18221 100644 --- a/frontend/src/store/__tests__/streamProfiles.test.jsx +++ b/frontend/src/store/__tests__/streamProfiles.test.jsx @@ -47,7 +47,9 @@ describe('useStreamProfilesStore', () => { const mockError = new Error('Network error'); api.getStreamProfiles.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useStreamProfilesStore()); @@ -57,7 +59,10 @@ describe('useStreamProfilesStore', () => { expect(result.current.error).toBe('Failed to load profiles.'); expect(result.current.isLoading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch profiles:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch profiles:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -152,7 +157,11 @@ describe('useStreamProfilesStore', () => { result.current.updateStreamProfile(updatedProfile); }); - expect(result.current.profiles[1]).toEqual({ id: 2, name: 'Profile 2', bitrate: 8000 }); + expect(result.current.profiles[1]).toEqual({ + id: 2, + name: 'Profile 2', + bitrate: 8000, + }); }); it('should not modify profiles when updating non-existent profile', () => { @@ -166,7 +175,11 @@ describe('useStreamProfilesStore', () => { }); const { result } = renderHook(() => useStreamProfilesStore()); - const nonExistentProfile = { id: 999, name: 'Non-existent', bitrate: 10000 }; + const nonExistentProfile = { + id: 999, + name: 'Non-existent', + bitrate: 10000, + }; act(() => { result.current.updateStreamProfile(nonExistentProfile); @@ -246,9 +259,7 @@ describe('useStreamProfilesStore', () => { }); it('should handle empty array when removing profiles', () => { - const initialProfiles = [ - { id: 1, name: 'Profile 1', bitrate: 5000 }, - ]; + const initialProfiles = [{ id: 1, name: 'Profile 1', bitrate: 5000 }]; useStreamProfilesStore.setState({ profiles: initialProfiles, diff --git a/frontend/src/store/__tests__/streams.test.jsx b/frontend/src/store/__tests__/streams.test.jsx index e6ed3952..c74d27ee 100644 --- a/frontend/src/store/__tests__/streams.test.jsx +++ b/frontend/src/store/__tests__/streams.test.jsx @@ -53,7 +53,9 @@ describe('useStreamsStore', () => { const mockError = new Error('Network error'); api.getStreams.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useStreamsStore()); @@ -63,7 +65,10 @@ describe('useStreamsStore', () => { expect(result.current.error).toBe('Failed to load streams.'); expect(result.current.isLoading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch streams:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch streams:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -131,7 +136,11 @@ describe('useStreamsStore', () => { }); const { result } = renderHook(() => useStreamsStore()); - const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }; + const updatedStream = { + id: 1, + name: 'Updated Stream', + url: 'http://example.com/updated', + }; act(() => { result.current.updateStream(updatedStream); @@ -152,13 +161,21 @@ describe('useStreamsStore', () => { }); const { result } = renderHook(() => useStreamsStore()); - const updatedStream = { id: 1, name: 'Updated Stream', url: 'http://example.com/updated' }; + const updatedStream = { + id: 1, + name: 'Updated Stream', + url: 'http://example.com/updated', + }; act(() => { result.current.updateStream(updatedStream); }); - expect(result.current.streams[1]).toEqual({ id: 2, name: 'Stream 2', url: 'http://example.com/2' }); + expect(result.current.streams[1]).toEqual({ + id: 2, + name: 'Stream 2', + url: 'http://example.com/2', + }); }); it('should not modify streams when updating non-existent stream', () => { @@ -172,7 +189,11 @@ describe('useStreamsStore', () => { }); const { result } = renderHook(() => useStreamsStore()); - const nonExistentStream = { id: 999, name: 'Non-existent', url: 'http://example.com/999' }; + const nonExistentStream = { + id: 999, + name: 'Non-existent', + url: 'http://example.com/999', + }; act(() => { result.current.updateStream(nonExistentStream); diff --git a/frontend/src/store/__tests__/streamsTable.test.jsx b/frontend/src/store/__tests__/streamsTable.test.jsx index 62818107..49715ce4 100644 --- a/frontend/src/store/__tests__/streamsTable.test.jsx +++ b/frontend/src/store/__tests__/streamsTable.test.jsx @@ -16,7 +16,7 @@ const localStorageMock = (() => { }), removeItem: vi.fn((key) => { delete store[key]; - }) + }), }; })(); @@ -58,7 +58,7 @@ describe('useStreamsTableStore', () => { pagination: { pageIndex: 0, pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, - } + }, }); const { result } = renderHook(() => useStreamsTableStore()); @@ -74,7 +74,7 @@ describe('useStreamsTableStore', () => { pagination: { pageIndex: 0, pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, - } + }, }); const { result } = renderHook(() => useStreamsTableStore()); @@ -112,10 +112,7 @@ describe('useStreamsTableStore', () => { const mockParams = new URLSearchParams({ page_size: '25' }); act(() => { - result.current.queryStreams( - { results: [], count: 75 }, - mockParams - ); + result.current.queryStreams({ results: [], count: 75 }, mockParams); }); expect(result.current.pageCount).toBe(3); // Math.ceil(75 / 25) @@ -127,10 +124,7 @@ describe('useStreamsTableStore', () => { const mockParams = new URLSearchParams({ page_size: '50' }); act(() => { - result.current.queryStreams( - { results: [], count: 0 }, - mockParams - ); + result.current.queryStreams({ results: [], count: 0 }, mockParams); }); expect(result.current.streams).toEqual([]); @@ -340,7 +334,9 @@ describe('useStreamsTableStore', () => { expect(result.current.totalCount).toBe(50); expect(result.current.pageCount).toBe(2); expect(result.current.pagination).toEqual({ pageIndex: 0, pageSize: 25 }); - expect(result.current.sorting).toEqual([{ id: 'created_at', desc: true }]); + expect(result.current.sorting).toEqual([ + { id: 'created_at', desc: true }, + ]); expect(result.current.allQueryIds).toEqual([1, 2, 3, 4, 5]); expect(result.current.selectedStreamIds).toEqual([1, 2]); expect(result.current.lastQueryParams).toBe(mockParams); diff --git a/frontend/src/store/__tests__/useVODStore.test.jsx b/frontend/src/store/__tests__/useVODStore.test.jsx index 5734e5a2..490fcbe3 100644 --- a/frontend/src/store/__tests__/useVODStore.test.jsx +++ b/frontend/src/store/__tests__/useVODStore.test.jsx @@ -103,7 +103,12 @@ describe('useVODStore', () => { expect(api.getAllContent).toHaveBeenCalled(); expect(result.current.currentPageContent).toEqual([ { id: 1, name: 'Movie 1', content_type: 'movie', contentType: 'movie' }, - { id: 2, name: 'Series 1', content_type: 'series', contentType: 'series' }, + { + id: 2, + name: 'Series 1', + content_type: 'series', + contentType: 'series', + }, ]); expect(result.current.totalCount).toBe(2); expect(result.current.loading).toBe(false); @@ -171,7 +176,9 @@ describe('useVODStore', () => { const mockError = new Error('Network error'); api.getAllContent.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -181,7 +188,10 @@ describe('useVODStore', () => { expect(result.current.error).toBe('Failed to load content.'); expect(result.current.loading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch content:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch content:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -189,7 +199,9 @@ describe('useVODStore', () => { it('should handle invalid response format', async () => { api.getAllContent.mockResolvedValue({ results: 'not-an-array' }); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -234,7 +246,9 @@ describe('useVODStore', () => { const mockError = new Error('Not found'); api.getMovieDetails.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -248,7 +262,10 @@ describe('useVODStore', () => { expect(result.current.error).toBe('Failed to load movie details.'); expect(result.current.loading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch movie details:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -283,7 +300,9 @@ describe('useVODStore', () => { const mockError = new Error('Provider error'); api.getMovieProviderInfo.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -295,8 +314,13 @@ describe('useVODStore', () => { } }); - expect(result.current.error).toBe('Failed to load movie details from provider.'); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie details from provider:', mockError); + expect(result.current.error).toBe( + 'Failed to load movie details from provider.' + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch movie details from provider:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -324,7 +348,9 @@ describe('useVODStore', () => { const mockError = new Error('Providers error'); api.getMovieProviders.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -336,7 +362,10 @@ describe('useVODStore', () => { } }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch movie providers:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch movie providers:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -361,7 +390,9 @@ describe('useVODStore', () => { const mockError = new Error('Series providers error'); api.getSeriesProviders.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -373,7 +404,10 @@ describe('useVODStore', () => { } }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series providers:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch series providers:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -428,7 +462,9 @@ describe('useVODStore', () => { const mockError = new Error('Series not found'); api.getSeriesInfo.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -442,7 +478,10 @@ describe('useVODStore', () => { expect(result.current.error).toBe('Failed to load series details.'); expect(result.current.loading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch series info:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch series info:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -494,7 +533,9 @@ describe('useVODStore', () => { const mockError = new Error('Categories error'); api.getVODCategories.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODStore()); @@ -503,7 +544,10 @@ describe('useVODStore', () => { }); expect(result.current.error).toBe('Failed to load categories.'); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD categories:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch VOD categories:', + mockError + ); consoleErrorSpy.mockRestore(); }); diff --git a/frontend/src/store/__tests__/useVideoStore.test.jsx b/frontend/src/store/__tests__/useVideoStore.test.jsx index b1f87d6d..53dfb594 100644 --- a/frontend/src/store/__tests__/useVideoStore.test.jsx +++ b/frontend/src/store/__tests__/useVideoStore.test.jsx @@ -68,7 +68,9 @@ describe('useVideoStore', () => { const { result } = renderHook(() => useVideoStore()); act(() => { - result.current.showVideo('http://example.com/stream.ts', 'vod', { title: 'Test' }); + result.current.showVideo('http://example.com/stream.ts', 'vod', { + title: 'Test', + }); }); expect(result.current.isVisible).toBe(true); @@ -121,7 +123,7 @@ describe('useVideoStore', () => { const metadata = { title: 'Test Video', duration: 120, - thumbnailUrl: 'http://example.com/thumb.jpg' + thumbnailUrl: 'http://example.com/thumb.jpg', }; act(() => { @@ -139,13 +141,21 @@ describe('useVideoStore', () => { const secondMetadata = { title: 'Second Video' }; act(() => { - result.current.showVideo('http://example.com/first.mp4', 'vod', firstMetadata); + result.current.showVideo( + 'http://example.com/first.mp4', + 'vod', + firstMetadata + ); }); expect(result.current.metadata).toEqual(firstMetadata); act(() => { - result.current.showVideo('http://example.com/second.mp4', 'vod', secondMetadata); + result.current.showVideo( + 'http://example.com/second.mp4', + 'vod', + secondMetadata + ); }); expect(result.current.metadata).toEqual(secondMetadata); diff --git a/frontend/src/store/__tests__/userAgents.test.jsx b/frontend/src/store/__tests__/userAgents.test.jsx index 8e804bec..bfa1855d 100644 --- a/frontend/src/store/__tests__/userAgents.test.jsx +++ b/frontend/src/store/__tests__/userAgents.test.jsx @@ -47,7 +47,9 @@ describe('useUserAgentsStore', () => { const mockError = new Error('Network error'); api.getUserAgents.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useUserAgentsStore()); @@ -57,7 +59,10 @@ describe('useUserAgentsStore', () => { expect(result.current.error).toBe('Failed to load userAgents.'); expect(result.current.isLoading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch userAgents:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch userAgents:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -125,7 +130,11 @@ describe('useUserAgentsStore', () => { }); const { result } = renderHook(() => useUserAgentsStore()); - const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }; + const updatedUserAgent = { + id: 1, + name: 'Chrome Updated', + string: 'Mozilla/5.0 Updated...', + }; act(() => { result.current.updateUserAgent(updatedUserAgent); @@ -146,13 +155,21 @@ describe('useUserAgentsStore', () => { }); const { result } = renderHook(() => useUserAgentsStore()); - const updatedUserAgent = { id: 1, name: 'Chrome Updated', string: 'Mozilla/5.0 Updated...' }; + const updatedUserAgent = { + id: 1, + name: 'Chrome Updated', + string: 'Mozilla/5.0 Updated...', + }; act(() => { result.current.updateUserAgent(updatedUserAgent); }); - expect(result.current.userAgents[1]).toEqual({ id: 2, name: 'Firefox', string: 'Mozilla/5.0...' }); + expect(result.current.userAgents[1]).toEqual({ + id: 2, + name: 'Firefox', + string: 'Mozilla/5.0...', + }); }); it('should not modify user agents when updating non-existent user agent', () => { @@ -166,7 +183,11 @@ describe('useUserAgentsStore', () => { }); const { result } = renderHook(() => useUserAgentsStore()); - const nonExistentUserAgent = { id: 999, name: 'Non-existent', string: 'Mozilla/5.0...' }; + const nonExistentUserAgent = { + id: 999, + name: 'Non-existent', + string: 'Mozilla/5.0...', + }; act(() => { result.current.updateUserAgent(nonExistentUserAgent); diff --git a/frontend/src/store/__tests__/users.test.jsx b/frontend/src/store/__tests__/users.test.jsx index 58b343ba..b48c954a 100644 --- a/frontend/src/store/__tests__/users.test.jsx +++ b/frontend/src/store/__tests__/users.test.jsx @@ -47,7 +47,9 @@ describe('useUsersStore', () => { const mockError = new Error('Network error'); api.getUsers.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useUsersStore()); @@ -57,7 +59,10 @@ describe('useUsersStore', () => { expect(result.current.error).toBe('Failed to load users.'); expect(result.current.isLoading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch users:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch users:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -125,7 +130,11 @@ describe('useUsersStore', () => { }); const { result } = renderHook(() => useUsersStore()); - const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' }; + const updatedUser = { + id: 1, + name: 'Updated User', + email: 'updated@example.com', + }; act(() => { result.current.updateUser(updatedUser); @@ -146,13 +155,21 @@ describe('useUsersStore', () => { }); const { result } = renderHook(() => useUsersStore()); - const updatedUser = { id: 1, name: 'Updated User', email: 'updated@example.com' }; + const updatedUser = { + id: 1, + name: 'Updated User', + email: 'updated@example.com', + }; act(() => { result.current.updateUser(updatedUser); }); - expect(result.current.users[1]).toEqual({ id: 2, name: 'User 2', email: 'user2@example.com' }); + expect(result.current.users[1]).toEqual({ + id: 2, + name: 'User 2', + email: 'user2@example.com', + }); }); it('should not modify users when updating non-existent user', () => { @@ -166,7 +183,11 @@ describe('useUsersStore', () => { }); const { result } = renderHook(() => useUsersStore()); - const nonExistentUser = { id: 999, name: 'Non-existent', email: 'none@example.com' }; + const nonExistentUser = { + id: 999, + name: 'Non-existent', + email: 'none@example.com', + }; act(() => { result.current.updateUser(nonExistentUser); @@ -252,7 +273,15 @@ describe('useUsersStore', () => { result.current.removeUser(2); }); - expect(result.current.users[0]).toEqual({ id: 1, name: 'User 1', email: 'user1@example.com' }); - expect(result.current.users[1]).toEqual({ id: 3, name: 'User 3', email: 'user3@example.com' }); + expect(result.current.users[0]).toEqual({ + id: 1, + name: 'User 1', + email: 'user1@example.com', + }); + expect(result.current.users[1]).toEqual({ + id: 3, + name: 'User 3', + email: 'user3@example.com', + }); }); }); diff --git a/frontend/src/store/__tests__/vodLogos.test.jsx b/frontend/src/store/__tests__/vodLogos.test.jsx index 116cbf9d..77c702b8 100644 --- a/frontend/src/store/__tests__/vodLogos.test.jsx +++ b/frontend/src/store/__tests__/vodLogos.test.jsx @@ -105,7 +105,9 @@ describe('useVODLogosStore', () => { const mockError = new Error('Network error'); api.getVODLogos.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODLogosStore()); @@ -119,7 +121,10 @@ describe('useVODLogosStore', () => { expect(result.current.error).toBe('Failed to load VOD logos.'); expect(result.current.isLoading).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch VOD logos:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch VOD logos:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -205,9 +210,7 @@ describe('useVODLogosStore', () => { expect(result.current.vodLogos).toEqual({ 2: { id: 2, name: 'Logo 2' }, }); - expect(result.current.logos).toEqual([ - { id: 2, name: 'Logo 2' }, - ]); + expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]); expect(result.current.totalCount).toBe(1); }); @@ -236,9 +239,7 @@ describe('useVODLogosStore', () => { expect(result.current.vodLogos).toEqual({ 2: { id: 2, name: 'Logo 2' }, }); - expect(result.current.logos).toEqual([ - { id: 2, name: 'Logo 2' }, - ]); + expect(result.current.logos).toEqual([{ id: 2, name: 'Logo 2' }]); expect(result.current.totalCount).toBe(1); }); @@ -246,7 +247,9 @@ describe('useVODLogosStore', () => { const mockError = new Error('Delete failed'); api.deleteVODLogo.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODLogosStore()); @@ -258,7 +261,10 @@ describe('useVODLogosStore', () => { } }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logo:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to delete VOD logo:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -290,9 +296,7 @@ describe('useVODLogosStore', () => { expect(result.current.vodLogos).toEqual({ 3: { id: 3, name: 'Logo 3' }, }); - expect(result.current.logos).toEqual([ - { id: 3, name: 'Logo 3' }, - ]); + expect(result.current.logos).toEqual([{ id: 3, name: 'Logo 3' }]); expect(result.current.totalCount).toBe(1); }); @@ -300,7 +304,9 @@ describe('useVODLogosStore', () => { const mockError = new Error('Bulk delete failed'); api.deleteVODLogos.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODLogosStore()); @@ -312,7 +318,10 @@ describe('useVODLogosStore', () => { } }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to delete VOD logos:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to delete VOD logos:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -348,7 +357,9 @@ describe('useVODLogosStore', () => { const mockError = new Error('Cleanup failed'); api.cleanupUnusedVODLogos.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODLogosStore()); @@ -360,7 +371,10 @@ describe('useVODLogosStore', () => { } }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to cleanup unused VOD logos:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to cleanup unused VOD logos:', + mockError + ); consoleErrorSpy.mockRestore(); }); @@ -517,7 +531,9 @@ describe('useVODLogosStore', () => { const mockError = new Error('Failed to fetch count'); api.getVODLogos.mockRejectedValue(mockError); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const { result } = renderHook(() => useVODLogosStore()); @@ -529,7 +545,10 @@ describe('useVODLogosStore', () => { } }); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch unused logos count:', mockError); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to fetch unused logos count:', + mockError + ); consoleErrorSpy.mockRestore(); }); diff --git a/frontend/src/store/channels.jsx b/frontend/src/store/channels.jsx index a95959b2..7646fca9 100644 --- a/frontend/src/store/channels.jsx +++ b/frontend/src/store/channels.jsx @@ -12,9 +12,15 @@ const reduceChannels = (channels) => { return acc; }, {}); return { channelsByUUID, channelsByID }; -} +}; -const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByUUID, channels) => { +const showNotificationIfNewChannel = ( + currentStats, + oldChannels, + ch, + channelsByUUID, + channels +) => { if (currentStats.channels) { if (oldChannels[ch.channel_id] === undefined) { // Add null checks to prevent accessing properties on undefined @@ -30,7 +36,7 @@ const showNotificationIfNewChannel = (currentStats, oldChannels, ch, channelsByU } } } -} +}; const showNotificationIfNewClient = (currentStats, oldClients, client) => { // This check prevents the notifications if streams are active on page load @@ -43,9 +49,15 @@ const showNotificationIfNewClient = (currentStats, oldClients, client) => { }); } } -} +}; -const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels, channelsByUUID, channels) => { +const showNotificationIfChannelStopped = ( + currentStats, + oldChannels, + newChannels, + channelsByUUID, + channels +) => { // This check prevents the notifications if streams are active on page load if (currentStats.channels) { for (const uuid in oldChannels) { @@ -70,9 +82,13 @@ const showNotificationIfChannelStopped = (currentStats, oldChannels, newChannels } } } -} +}; -const showNotificationIfClientStopped = (currentStats, oldClients, newClients) => { +const showNotificationIfClientStopped = ( + currentStats, + oldClients, + newClients +) => { if (currentStats.channels) { for (const clientId in oldClients) { if (newClients[clientId] === undefined) { @@ -84,7 +100,7 @@ const showNotificationIfClientStopped = (currentStats, oldClients, newClients) = } } } -} +}; const useChannelsStore = create((set, get) => ({ channels: [], @@ -228,7 +244,7 @@ const useChannelsStore = create((set, get) => ({ ); return; } - + const { channelsByUUID, updatedChannels } = reduceChannels(channels); set((state) => ({ @@ -383,24 +399,36 @@ const useChannelsStore = create((set, get) => ({ channelsByUUID, } = state; const newClients = {}; - + const newChannels = stats.channels.reduce((acc, ch) => { acc[ch.channel_id] = ch; return acc; }, {}); - stats.channels.forEach(ch => { - showNotificationIfNewChannel(currentStats, oldChannels, ch, channelsByUUID, channels); + stats.channels.forEach((ch) => { + showNotificationIfNewChannel( + currentStats, + oldChannels, + ch, + channelsByUUID, + channels + ); - ch.clients.forEach(client => { - newClients[client.client_id] = client; - showNotificationIfNewClient(currentStats, oldClients, client); - }); + ch.clients.forEach((client) => { + newClients[client.client_id] = client; + showNotificationIfNewClient(currentStats, oldClients, client); + }); }); - showNotificationIfChannelStopped(currentStats, oldChannels, newChannels, channelsByUUID, channels); + showNotificationIfChannelStopped( + currentStats, + oldChannels, + newChannels, + channelsByUUID, + channels + ); showNotificationIfClientStopped(currentStats, oldClients, newClients); - + return { stats, activeChannels: newChannels, diff --git a/frontend/src/store/epgs.jsx b/frontend/src/store/epgs.jsx index e8f5e2ad..d8a54215 100644 --- a/frontend/src/store/epgs.jsx +++ b/frontend/src/store/epgs.jsx @@ -4,7 +4,8 @@ import api from '../api'; const determineEPGStatus = (data, currentEpg) => { if (data.status) return data.status; if (data.action === 'downloading') return 'fetching'; - if (data.action === 'parsing_channels' || data.action === 'parsing_programs') return 'parsing'; + if (data.action === 'parsing_channels' || data.action === 'parsing_programs') + return 'parsing'; if (data.progress === 100) return 'success'; return currentEpg?.status || 'idle'; }; @@ -118,23 +119,26 @@ const useEPGsStore = create((set) => ({ // Only update epgs object if status or last_message actually changed // This prevents unnecessary re-renders on every progress update - const lastMessage = data.status === 'error' - ? (data.error || 'Unknown error') - : state.epgs[data.source]?.last_message; + const lastMessage = + data.status === 'error' + ? data.error || 'Unknown error' + : state.epgs[data.source]?.last_message; const currentEpg = state.epgs[data.source]; - const shouldUpdateEpg = currentEpg && - (currentEpg.status !== status || currentEpg.last_message !== lastMessage); + const shouldUpdateEpg = + currentEpg && + (currentEpg.status !== status || + currentEpg.last_message !== lastMessage); const epgs = shouldUpdateEpg ? { - ...state.epgs, - [data.source]: { - ...currentEpg, - status, - last_message: lastMessage, - }, - } + ...state.epgs, + [data.source]: { + ...currentEpg, + status, + last_message: lastMessage, + }, + } : state.epgs; return { refreshProgress, epgs }; diff --git a/frontend/src/store/logos.jsx b/frontend/src/store/logos.jsx index f821c424..2270a84c 100644 --- a/frontend/src/store/logos.jsx +++ b/frontend/src/store/logos.jsx @@ -2,10 +2,8 @@ import { create } from 'zustand'; import api from '../api'; const getLogosArray = (response) => { - return Array.isArray(response) - ? response - : response.results || []; -} + return Array.isArray(response) ? response : response.results || []; +}; const useLogosStore = create((set, get) => ({ logos: {}, diff --git a/frontend/src/store/plugins.jsx b/frontend/src/store/plugins.jsx index e8d0b065..9fb61a47 100644 --- a/frontend/src/store/plugins.jsx +++ b/frontend/src/store/plugins.jsx @@ -38,4 +38,4 @@ export const usePluginStore = create((set, get) => ({ set({ plugins: [] }); get().fetchPlugins(); }, -})); \ No newline at end of file +})); diff --git a/frontend/src/store/streamsTable.jsx b/frontend/src/store/streamsTable.jsx index 2057a6e2..616be9c2 100644 --- a/frontend/src/store/streamsTable.jsx +++ b/frontend/src/store/streamsTable.jsx @@ -7,8 +7,7 @@ const useStreamsTableStore = create((set) => ({ sorting: [{ id: 'name', desc: false }], pagination: { pageIndex: 0, - pageSize: - JSON.parse(localStorage.getItem('streams-page-size')) || 50, + pageSize: JSON.parse(localStorage.getItem('streams-page-size')) || 50, }, selectedStreamIds: [], allQueryIds: [], diff --git a/frontend/src/store/useVODStore.jsx b/frontend/src/store/useVODStore.jsx index 0d8fe446..966c4a3e 100644 --- a/frontend/src/store/useVODStore.jsx +++ b/frontend/src/store/useVODStore.jsx @@ -14,7 +14,7 @@ const getFetchContentParams = (state) => { params.append('category', state.filters.category); } return params; -} +}; const getMovieDetails = (response, movieId) => { return { @@ -35,7 +35,7 @@ const getMovieDetails = (response, movieId) => { imdb_id: response.imdb_id || '', m3u_account: response.m3u_account || '', }; -} +}; const getMovieDetailsWithProvider = (response, movieId) => { return { @@ -65,7 +65,7 @@ const getMovieDetailsWithProvider = (response, movieId) => { video: response.video || {}, audio: response.audio || {}, }; -} +}; const getSeriesDetails = (response, seriesId) => { return { @@ -92,7 +92,7 @@ const getSeriesDetails = (response, seriesId) => { m3u_account: response.m3u_account || '', youtube_trailer: response.custom_properties?.youtube_trailer || '', }; -} +}; const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => { return { @@ -117,7 +117,7 @@ const getEpisodeDetails = (episode, seasonNumber, seriesInfo) => { tmdb_id: episode.tmdb_id || '', imdb_id: episode.imdb_id || '', }; -} +}; const useVODStore = create((set, get) => ({ content: {}, // Store for individual content details (when fetching movie/series details) diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 62d8190e..0299b240 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -256,7 +256,7 @@ describe('dateTimeUtils', () => { const setTimeZone = vi.fn(); useLocalStorage.mockReturnValue(['America/New_York', setTimeZone]); useSettingsStore.mockReturnValue({ - 'system_settings': { value: { time_zone: 'America/Los_Angeles' } } + system_settings: { value: { time_zone: 'America/Los_Angeles' } }, }); renderHook(() => dateTimeUtils.useUserTimeZone()); @@ -321,18 +321,25 @@ describe('dateTimeUtils', () => { }); it('should start with Sunday', () => { - expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ value: 6, label: 'Sun' }); + expect(dateTimeUtils.RECURRING_DAY_OPTIONS[0]).toEqual({ + value: 6, + label: 'Sun', + }); }); it('should include all weekdays', () => { - const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map(opt => opt.label); + const labels = dateTimeUtils.RECURRING_DAY_OPTIONS.map( + (opt) => opt.label + ); expect(labels).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); }); }); describe('useDateTimeFormat', () => { it('should return 12h format and mdy date format by default', () => { - useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]); + useLocalStorage + .mockReturnValueOnce(['12h', vi.fn()]) + .mockReturnValueOnce(['mdy', vi.fn()]); const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat()); @@ -341,7 +348,9 @@ describe('dateTimeUtils', () => { }); it('should return 24h format when set', () => { - useLocalStorage.mockReturnValueOnce(['24h', vi.fn()]).mockReturnValueOnce(['mdy', vi.fn()]); + useLocalStorage + .mockReturnValueOnce(['24h', vi.fn()]) + .mockReturnValueOnce(['mdy', vi.fn()]); const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat()); @@ -349,7 +358,9 @@ describe('dateTimeUtils', () => { }); it('should return dmy date format when set', () => { - useLocalStorage.mockReturnValueOnce(['12h', vi.fn()]).mockReturnValueOnce(['dmy', vi.fn()]); + useLocalStorage + .mockReturnValueOnce(['12h', vi.fn()]) + .mockReturnValueOnce(['dmy', vi.fn()]); const { result } = renderHook(() => dateTimeUtils.useDateTimeFormat()); @@ -428,26 +439,28 @@ describe('dateTimeUtils', () => { it('should sort by offset then name', () => { const result = dateTimeUtils.buildTimeZoneOptions(); for (let i = 1; i < result.length; i++) { - expect(result[i].numericOffset).toBeGreaterThanOrEqual(result[i - 1].numericOffset); + expect(result[i].numericOffset).toBeGreaterThanOrEqual( + result[i - 1].numericOffset + ); } }); it('should include DST information when applicable', () => { const result = dateTimeUtils.buildTimeZoneOptions(); - const dstZone = result.find(opt => opt.label.includes('DST range')); + const dstZone = result.find((opt) => opt.label.includes('DST range')); expect(dstZone).toBeDefined(); }); it('should add preferred zone if not in list', () => { const preferredZone = 'Custom/Zone'; const result = dateTimeUtils.buildTimeZoneOptions(preferredZone); - const found = result.find(opt => opt.value === preferredZone); + const found = result.find((opt) => opt.value === preferredZone); expect(found).toBeDefined(); }); it('should not duplicate existing zones', () => { const result = dateTimeUtils.buildTimeZoneOptions('UTC'); - const utcOptions = result.filter(opt => opt.value === 'UTC'); + const utcOptions = result.filter((opt) => opt.value === 'UTC'); expect(utcOptions).toHaveLength(1); }); }); diff --git a/frontend/src/utils/__tests__/networkUtils.test.js b/frontend/src/utils/__tests__/networkUtils.test.js index bb820589..01522403 100644 --- a/frontend/src/utils/__tests__/networkUtils.test.js +++ b/frontend/src/utils/__tests__/networkUtils.test.js @@ -8,7 +8,9 @@ describe('networkUtils', () => { expect(networkUtils.IPV4_CIDR_REGEX.test('10.0.0.0/8')).toBe(true); expect(networkUtils.IPV4_CIDR_REGEX.test('172.16.0.0/12')).toBe(true); expect(networkUtils.IPV4_CIDR_REGEX.test('0.0.0.0/0')).toBe(true); - expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe(true); + expect(networkUtils.IPV4_CIDR_REGEX.test('255.255.255.255/32')).toBe( + true + ); }); it('should not match invalid IPv4 CIDR notation', () => { @@ -29,7 +31,11 @@ describe('networkUtils', () => { expect(networkUtils.IPV6_CIDR_REGEX.test('2001:db8::/32')).toBe(true); expect(networkUtils.IPV6_CIDR_REGEX.test('fe80::/10')).toBe(true); expect(networkUtils.IPV6_CIDR_REGEX.test('::/0')).toBe(true); - expect(networkUtils.IPV6_CIDR_REGEX.test('2001:0db8:85a3:0000:0000:8a2e:0370:7334/64')).toBe(true); + expect( + networkUtils.IPV6_CIDR_REGEX.test( + '2001:0db8:85a3:0000:0000:8a2e:0370:7334/64' + ) + ).toBe(true); }); it('should match compressed IPv6 CIDR notation', () => { @@ -38,7 +44,9 @@ describe('networkUtils', () => { }); it('should match IPv6 with embedded IPv4', () => { - expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe(true); + expect(networkUtils.IPV6_CIDR_REGEX.test('::ffff:192.168.1.1/96')).toBe( + true + ); }); it('should not match invalid IPv6 CIDR notation', () => { diff --git a/frontend/src/utils/__tests__/notificationUtils.test.js b/frontend/src/utils/__tests__/notificationUtils.test.js index bfea55d8..04442b73 100644 --- a/frontend/src/utils/__tests__/notificationUtils.test.js +++ b/frontend/src/utils/__tests__/notificationUtils.test.js @@ -74,7 +74,10 @@ describe('notificationUtils', () => { notificationUtils.updateNotification(notificationId, notificationObject); - expect(notifications.update).toHaveBeenCalledWith(notificationId, notificationObject); + expect(notifications.update).toHaveBeenCalledWith( + notificationId, + notificationObject + ); expect(notifications.update).toHaveBeenCalledTimes(1); }); @@ -82,7 +85,9 @@ describe('notificationUtils', () => { const mockReturnValue = { success: true }; notifications.update.mockReturnValue(mockReturnValue); - const result = notificationUtils.updateNotification('id', { message: 'test' }); + const result = notificationUtils.updateNotification('id', { + message: 'test', + }); expect(result).toBe(mockReturnValue); }); @@ -98,7 +103,10 @@ describe('notificationUtils', () => { notificationUtils.updateNotification(notificationId, updateObject); - expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject); + expect(notifications.update).toHaveBeenCalledWith( + notificationId, + updateObject + ); }); it('should handle loading to error transition', () => { @@ -112,7 +120,10 @@ describe('notificationUtils', () => { notificationUtils.updateNotification(notificationId, updateObject); - expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject); + expect(notifications.update).toHaveBeenCalledWith( + notificationId, + updateObject + ); }); it('should handle partial updates', () => { @@ -123,7 +134,10 @@ describe('notificationUtils', () => { notificationUtils.updateNotification(notificationId, updateObject); - expect(notifications.update).toHaveBeenCalledWith(notificationId, updateObject); + expect(notifications.update).toHaveBeenCalledWith( + notificationId, + updateObject + ); }); it('should handle empty notification id', () => { @@ -139,7 +153,10 @@ describe('notificationUtils', () => { notificationUtils.updateNotification(null, notificationObject); - expect(notifications.update).toHaveBeenCalledWith(null, notificationObject); + expect(notifications.update).toHaveBeenCalledWith( + null, + notificationObject + ); }); }); }); diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js index 65b3da3a..6232064b 100644 --- a/frontend/src/utils/cards/RecordingCardUtils.js +++ b/frontend/src/utils/cards/RecordingCardUtils.js @@ -89,4 +89,4 @@ export const getSeriesInfo = (customProps) => { const cp = customProps || {}; const pr = cp.program || {}; return { tvg_id: pr.tvg_id, title: pr.title }; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/cards/VodConnectionCardUtils.js b/frontend/src/utils/cards/VodConnectionCardUtils.js index bf0a2219..5bec7d9a 100644 --- a/frontend/src/utils/cards/VodConnectionCardUtils.js +++ b/frontend/src/utils/cards/VodConnectionCardUtils.js @@ -24,7 +24,7 @@ export const formatTime = (seconds) => { export const getMovieDisplayTitle = (vodContent) => { return vodContent.content_name; -} +}; export const getEpisodeDisplayTitle = (metadata) => { const season = metadata.season_number @@ -34,18 +34,18 @@ export const getEpisodeDisplayTitle = (metadata) => { ? `E${metadata.episode_number.toString().padStart(2, '0')}` : 'E??'; return `${metadata.series_name} - ${season}${episode}`; -} +}; export const getMovieSubtitle = (metadata) => { const parts = []; if (metadata.genre) parts.push(metadata.genre); // We'll handle rating separately as a badge now return parts; -} +}; export const getEpisodeSubtitle = (metadata) => { return [metadata.episode_name || 'Episode']; -} +}; export const calculateProgress = (connection, duration_secs) => { if (!connection || !duration_secs) { @@ -92,7 +92,7 @@ export const calculateProgress = (connection, duration_secs) => { currentTime: Math.max(0, currentTime), // Don't go negative totalTime: totalSeconds, }; -} +}; export const calculateConnectionDuration = (connection) => { // If duration is provided by API, use it @@ -115,9 +115,12 @@ export const calculateConnectionDuration = (connection) => { } return 'Unknown duration'; -} +}; -export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => { +export const calculateConnectionStartTime = ( + connection, + fullDateTimeFormat +) => { if (connection.connected_at) { return format(connection.connected_at * 1000, fullDateTimeFormat); } @@ -136,4 +139,4 @@ export const calculateConnectionStartTime = (connection, fullDateTimeFormat) => } return 'Unknown'; -} \ No newline at end of file +}; diff --git a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js index a6074a4a..e17ff7eb 100644 --- a/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/PluginCardUtils.test.js @@ -1,7 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - getConfirmationDetails, -} from '../PluginCardUtils'; +import { getConfirmationDetails } from '../PluginCardUtils'; describe('PluginCardUtils', () => { describe('getConfirmationDetails', () => { @@ -13,7 +11,8 @@ describe('PluginCardUtils', () => { expect(result).toEqual({ requireConfirm: true, confirmTitle: 'Run Test Action?', - confirmMessage: 'You\'re about to run "Test Action" from "Test Plugin".', + confirmMessage: + 'You\'re about to run "Test Action" from "Test Plugin".', }); }); diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js index 3410c596..882b0fb1 100644 --- a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js @@ -156,7 +156,9 @@ describe('RecordingCardUtils', () => { const channel = { uuid: 'channel-123' }; const result = getShowVideoUrl(channel, 'dev'); - expect(result).toMatch(/^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/); + expect(result).toMatch( + /^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/ + ); }); }); @@ -208,7 +210,9 @@ describe('RecordingCardUtils', () => { it('handles bulk remove error gracefully', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(); - API.bulkRemoveSeriesRecordings.mockRejectedValue(new Error('Bulk remove failed')); + API.bulkRemoveSeriesRecordings.mockRejectedValue( + new Error('Bulk remove failed') + ); API.deleteSeriesRule.mockResolvedValue(); const seriesInfo = { tvg_id: 'series-123', title: 'Test Series' }; diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js index a1596280..5675425f 100644 --- a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js @@ -14,31 +14,43 @@ describe('StreamConnectionCardUtils', () => { describe('getBufferingSpeedThreshold', () => { it('should return parsed buffering_speed from proxy settings', () => { const proxySetting = { - value: { buffering_speed: 2.5 } + value: { buffering_speed: 2.5 }, }; - expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5); + expect( + StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting) + ).toBe(2.5); }); it('should return 1.0 for invalid JSON', () => { const proxySetting = { value: { buffering_speed: 'invalid' } }; - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + expect( + StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting) + ).toBe(1.0); consoleSpy.mockRestore(); }); it('should return 1.0 when buffering_speed is not a number', () => { const proxySetting = { - value: JSON.stringify({ buffering_speed: 'not a number' }) + value: JSON.stringify({ buffering_speed: 'not a number' }), }; - expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0); + expect( + StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting) + ).toBe(1.0); }); it('should return 1.0 when proxySetting is null', () => { - expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe(1.0); + expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(null)).toBe( + 1.0 + ); }); it('should return 1.0 when value is missing', () => { - expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe(1.0); + expect(StreamConnectionCardUtils.getBufferingSpeedThreshold({})).toBe( + 1.0 + ); }); }); @@ -60,17 +72,14 @@ describe('StreamConnectionCardUtils', () => { it('should create map from m3u accounts array', () => { const m3uAccounts = [ { id: 1, name: 'Account 1' }, - { id: 2, name: 'Account 2' } + { id: 2, name: 'Account 2' }, ]; const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts); expect(result).toEqual({ 1: 'Account 1', 2: 'Account 2' }); }); it('should handle accounts without id', () => { - const m3uAccounts = [ - { name: 'Account 1' }, - { id: 2, name: 'Account 2' } - ]; + const m3uAccounts = [{ name: 'Account 1' }, { id: 2, name: 'Account 2' }]; const result = StreamConnectionCardUtils.getM3uAccountsMap(m3uAccounts); expect(result).toEqual({ 2: 'Account 2' }); }); @@ -100,7 +109,7 @@ describe('StreamConnectionCardUtils', () => { it('should find stream when channelUrl includes stream url', () => { const streamData = [ { id: 1, url: 'http://example.com/stream1' }, - { id: 2, url: 'http://example.com/stream2' } + { id: 2, url: 'http://example.com/stream2' }, ]; const result = StreamConnectionCardUtils.getMatchingStreamByUrl( streamData, @@ -111,7 +120,7 @@ describe('StreamConnectionCardUtils', () => { it('should find stream when stream url includes channelUrl', () => { const streamData = [ - { id: 1, url: 'http://example.com/stream1/playlist.m3u8' } + { id: 1, url: 'http://example.com/stream1/playlist.m3u8' }, ]; const result = StreamConnectionCardUtils.getMatchingStreamByUrl( streamData, @@ -134,7 +143,7 @@ describe('StreamConnectionCardUtils', () => { it('should find stream by id as string', () => { const streams = [ { id: 1, name: 'Stream 1' }, - { id: 2, name: 'Stream 2' } + { id: 2, name: 'Stream 2' }, ]; const result = StreamConnectionCardUtils.getSelectedStream(streams, '2'); expect(result).toEqual(streams[1]); @@ -167,11 +176,20 @@ describe('StreamConnectionCardUtils', () => { dateTimeUtils.subtract.mockReturnValue(mockConnectedTime); dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00'); - const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY, HH:mm:ss'); + const accessor = StreamConnectionCardUtils.connectedAccessor( + 'MM/DD/YYYY, HH:mm:ss' + ); const result = accessor({ connected_since: 7200 }); - expect(dateTimeUtils.subtract).toHaveBeenCalledWith(mockNow, 7200, 'second'); - expect(dateTimeUtils.format).toHaveBeenCalledWith(mockConnectedTime, 'MM/DD/YYYY, HH:mm:ss'); + expect(dateTimeUtils.subtract).toHaveBeenCalledWith( + mockNow, + 7200, + 'second' + ); + expect(dateTimeUtils.format).toHaveBeenCalledWith( + mockConnectedTime, + 'MM/DD/YYYY, HH:mm:ss' + ); expect(result).toBe('01/01/2024 10:00:00'); }); @@ -181,7 +199,8 @@ describe('StreamConnectionCardUtils', () => { dateTimeUtils.initializeTime.mockReturnValue(mockTime); dateTimeUtils.format.mockReturnValue('01/01/2024 10:00:00'); - const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY'); + const accessor = + StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY'); const result = accessor({ connected_at: 1704103200 }); expect(dateTimeUtils.initializeTime).toHaveBeenCalledWith(1704103200000); @@ -189,7 +208,8 @@ describe('StreamConnectionCardUtils', () => { }); it('should return Unknown when no time data available', () => { - const accessor = StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY'); + const accessor = + StreamConnectionCardUtils.connectedAccessor('MM/DD/YYYY'); const result = accessor({}); expect(result).toBe('Unknown'); }); @@ -202,7 +222,10 @@ describe('StreamConnectionCardUtils', () => { const accessor = StreamConnectionCardUtils.durationAccessor(); const result = accessor({ connected_since: 9000 }); - expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(9000, 'seconds'); + expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith( + 9000, + 'seconds' + ); expect(result).toBe('2h 30m'); }); @@ -212,7 +235,10 @@ describe('StreamConnectionCardUtils', () => { const accessor = StreamConnectionCardUtils.durationAccessor(); const result = accessor({ connection_duration: 4500 }); - expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(4500, 'seconds'); + expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith( + 4500, + 'seconds' + ); expect(result).toBe('1h 15m'); }); @@ -226,15 +252,23 @@ describe('StreamConnectionCardUtils', () => { describe('getLogoUrl', () => { it('should return cache_url from logos map when logoId exists', () => { const logos = { - 'logo-123': { cache_url: '/api/logos/logo-123/cache/' } + 'logo-123': { cache_url: '/api/logos/logo-123/cache/' }, }; - const result = StreamConnectionCardUtils.getLogoUrl('logo-123', logos, null); + const result = StreamConnectionCardUtils.getLogoUrl( + 'logo-123', + logos, + null + ); expect(result).toBe('/api/logos/logo-123/cache/'); }); it('should fallback to previewedStream logo_url when logoId not in map', () => { const previewedStream = { logo_url: 'http://example.com/logo.png' }; - const result = StreamConnectionCardUtils.getLogoUrl('logo-456', {}, previewedStream); + const result = StreamConnectionCardUtils.getLogoUrl( + 'logo-456', + {}, + previewedStream + ); expect(result).toBe('http://example.com/logo.png'); }); @@ -260,15 +294,18 @@ describe('StreamConnectionCardUtils', () => { it('should format stream options with account names from map', () => { const streams = [ { id: 1, name: 'Stream 1', m3u_account: 100 }, - { id: 2, name: 'Stream 2', m3u_account: 200 } + { id: 2, name: 'Stream 2', m3u_account: 200 }, ]; const accountsMap = { 100: 'Premium Account', 200: 'Basic Account' }; - const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap); + const result = StreamConnectionCardUtils.getStreamOptions( + streams, + accountsMap + ); expect(result).toEqual([ { value: '1', label: 'Stream 1 [Premium Account]' }, - { value: '2', label: 'Stream 2 [Basic Account]' } + { value: '2', label: 'Stream 2 [Basic Account]' }, ]); }); @@ -284,7 +321,10 @@ describe('StreamConnectionCardUtils', () => { const streams = [{ id: 5, m3u_account: 100 }]; const accountsMap = { 100: 'Account' }; - const result = StreamConnectionCardUtils.getStreamOptions(streams, accountsMap); + const result = StreamConnectionCardUtils.getStreamOptions( + streams, + accountsMap + ); expect(result[0].label).toBe('Stream #5 [Account]'); }); diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js index 19b71a9d..489b781a 100644 --- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js @@ -96,7 +96,7 @@ describe('VodConnectionCardUtils', () => { const metadata = { series_name: 'Breaking Bad', season_number: 1, - episode_number: 5 + episode_number: 5, }; const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata); expect(result).toBe('Breaking Bad - S01E05'); @@ -106,7 +106,7 @@ describe('VodConnectionCardUtils', () => { const metadata = { series_name: 'The Office', season_number: 3, - episode_number: 9 + episode_number: 9, }; const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata); expect(result).toBe('The Office - S03E09'); @@ -115,7 +115,7 @@ describe('VodConnectionCardUtils', () => { it('should use S?? when season_number is missing', () => { const metadata = { series_name: 'Lost', - episode_number: 5 + episode_number: 5, }; const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata); expect(result).toBe('Lost - S??E05'); @@ -124,7 +124,7 @@ describe('VodConnectionCardUtils', () => { it('should use E?? when episode_number is missing', () => { const metadata = { series_name: 'Friends', - season_number: 2 + season_number: 2, }; const result = VodConnectionCardUtils.getEpisodeDisplayTitle(metadata); expect(result).toBe('Friends - S02E??'); @@ -167,7 +167,7 @@ describe('VodConnectionCardUtils', () => { it('should calculate progress from last_seek_percentage', () => { const connection = { last_seek_percentage: 50, - last_seek_timestamp: 990 // 10 seconds ago + last_seek_timestamp: 990, // 10 seconds ago }; const result = VodConnectionCardUtils.calculateProgress(connection, 200); @@ -179,7 +179,7 @@ describe('VodConnectionCardUtils', () => { it('should cap currentTime at duration when seeking', () => { const connection = { last_seek_percentage: 95, - last_seek_timestamp: 900 // 100 seconds ago + last_seek_timestamp: 900, // 100 seconds ago }; const result = VodConnectionCardUtils.calculateProgress(connection, 200); @@ -189,7 +189,7 @@ describe('VodConnectionCardUtils', () => { it('should fallback to position_seconds when seek data unavailable', () => { const connection = { - position_seconds: 75 + position_seconds: 75, }; const result = VodConnectionCardUtils.calculateProgress(connection, 200); @@ -218,7 +218,7 @@ describe('VodConnectionCardUtils', () => { it('should ensure currentTime is not negative', () => { const connection = { last_seek_percentage: 10, - last_seek_timestamp: 2000 // In the future somehow + last_seek_timestamp: 2000, // In the future somehow }; const result = VodConnectionCardUtils.calculateProgress(connection, 200); @@ -231,9 +231,13 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.toFriendlyDuration.mockReturnValue('1h 30m'); const connection = { duration: 5400 }; - const result = VodConnectionCardUtils.calculateConnectionDuration(connection); + const result = + VodConnectionCardUtils.calculateConnectionDuration(connection); - expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(5400, 'seconds'); + expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith( + 5400, + 'seconds' + ); expect(result).toBe('1h 30m'); }); @@ -242,22 +246,28 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.toFriendlyDuration.mockReturnValue('45m'); const connection = { client_id: 'vod_900000_abc' }; - const result = VodConnectionCardUtils.calculateConnectionDuration(connection); + const result = + VodConnectionCardUtils.calculateConnectionDuration(connection); - expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith(100, 'seconds'); + expect(dateTimeUtils.toFriendlyDuration).toHaveBeenCalledWith( + 100, + 'seconds' + ); expect(result).toBe('45m'); }); it('should return Unknown duration when no data available', () => { const connection = {}; - const result = VodConnectionCardUtils.calculateConnectionDuration(connection); + const result = + VodConnectionCardUtils.calculateConnectionDuration(connection); expect(result).toBe('Unknown duration'); }); it('should return Unknown duration when client_id is invalid format', () => { const connection = { client_id: 'invalid_format' }; - const result = VodConnectionCardUtils.calculateConnectionDuration(connection); + const result = + VodConnectionCardUtils.calculateConnectionDuration(connection); expect(result).toBe('Unknown duration'); }); @@ -267,7 +277,8 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.toFriendlyDuration.mockReturnValue('45m'); const connection = { client_id: 'vod_invalid_abc' }; - const result = VodConnectionCardUtils.calculateConnectionDuration(connection); + const result = + VodConnectionCardUtils.calculateConnectionDuration(connection); // If parseInt fails, the code should still handle it expect(result).toBe('45m'); // or 'Unknown duration' depending on implementation @@ -279,9 +290,15 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.format.mockReturnValue('01/15/2024 14:30:00'); const connection = { connected_at: 1705329000 }; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss'); + const result = VodConnectionCardUtils.calculateConnectionStartTime( + connection, + 'MM/DD/YYYY, HH:mm:ss' + ); - expect(dateTimeUtils.format).toHaveBeenCalledWith(1705329000000, 'MM/DD/YYYY, HH:mm:ss'); + expect(dateTimeUtils.format).toHaveBeenCalledWith( + 1705329000000, + 'MM/DD/YYYY, HH:mm:ss' + ); expect(result).toBe('01/15/2024 14:30:00'); }); @@ -289,22 +306,34 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00'); const connection = { client_id: 'vod_1705323600000_abc' }; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss'); + const result = VodConnectionCardUtils.calculateConnectionStartTime( + connection, + 'MM/DD/YYYY, HH:mm:ss' + ); - expect(dateTimeUtils.format).toHaveBeenCalledWith(1705323600000, 'MM/DD/YYYY, HH:mm:ss'); + expect(dateTimeUtils.format).toHaveBeenCalledWith( + 1705323600000, + 'MM/DD/YYYY, HH:mm:ss' + ); expect(result).toBe('01/15/2024 13:00:00'); }); it('should return Unknown when no timestamp data available', () => { const connection = {}; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY, HH:mm:ss'); + const result = VodConnectionCardUtils.calculateConnectionStartTime( + connection, + 'MM/DD/YYYY, HH:mm:ss' + ); expect(result).toBe('Unknown'); }); it('should return Unknown when client_id is invalid format', () => { const connection = { client_id: 'invalid_format' }; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY'); + const result = VodConnectionCardUtils.calculateConnectionStartTime( + connection, + 'MM/DD/YYYY' + ); expect(result).toBe('Unknown'); }); @@ -313,11 +342,13 @@ describe('VodConnectionCardUtils', () => { dateTimeUtils.format.mockReturnValue('01/15/2024 13:00:00'); const connection = { client_id: 'vod_notanumber_abc' }; - const result = VodConnectionCardUtils.calculateConnectionStartTime(connection, 'MM/DD/YYYY'); + const result = VodConnectionCardUtils.calculateConnectionStartTime( + connection, + 'MM/DD/YYYY' + ); // If parseInt succeeds on any number, format will be called expect(result).toBe('01/15/2024 13:00:00'); // or 'Unknown' depending on implementation }); - }); }); diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 2b9c0888..7993786d 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -40,7 +40,8 @@ export const format = (dateTime, formatStr) => export const getNow = () => dayjs(); -export const toFriendlyDuration = (dateTime, unit) => dayjs.duration(dateTime, unit).humanize(); +export const toFriendlyDuration = (dateTime, unit) => + dayjs.duration(dateTime, unit).humanize(); export const fromNow = (dateTime) => dayjs(dateTime).fromNow(); @@ -113,7 +114,8 @@ export const useDateTimeFormat = () => { const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM'; // Full format strings for detailed date-time displays - const fullDateFormat = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY'; + const fullDateFormat = + dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY'; const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss'; const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`; @@ -278,4 +280,4 @@ export const getDefaultTimeZone = () => { } catch (error) { return 'UTC'; } -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js index 805bc006..e3ea2496 100644 --- a/frontend/src/utils/forms/RecordingDetailsModalUtils.js +++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js @@ -33,7 +33,7 @@ const filterByUpcoming = (arr, tvid, titleKey, toUserTime, userNow) => { const st = toUserTime(r.start_time); return st.isAfter(userNow()); }); -} +}; const dedupeByProgram = (filtered) => { // Deduplicate by program.id if present, else by time+title @@ -62,7 +62,7 @@ const dedupeByProgram = (filtered) => { deduped.push(r); } return deduped; -} +}; export const getUpcomingEpisodes = ( isSeriesGroup, diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js index 1eb9194a..a1335c0c 100644 --- a/frontend/src/utils/forms/RecurringRuleModalUtils.js +++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js @@ -63,4 +63,4 @@ export const deleteRecurringRuleById = async (ruleId) => { export const updateRecurringRuleEnabled = async (ruleId, checked) => { await API.updateRecurringRule(ruleId, { enabled: checked }); -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js index af85dce4..ca689ff7 100644 --- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js @@ -15,7 +15,7 @@ describe('RecordingDetailsModalUtils', () => { audio_codec: 'AAC', audio_channels: 2, sample_rate: 48000, - audio_bitrate: 128 + audio_bitrate: 128, }; const result = RecordingDetailsModalUtils.getStatRows(stats); @@ -28,49 +28,45 @@ describe('RecordingDetailsModalUtils', () => { ['Audio Codec', 'AAC'], ['Audio Channels', 2], ['Sample Rate', '48000 Hz'], - ['Audio Bitrate', '128 kb/s'] + ['Audio Bitrate', '128 kb/s'], ]); }); it('should use width x height when resolution is not present', () => { const stats = { width: 1280, - height: 720 + height: 720, }; const result = RecordingDetailsModalUtils.getStatRows(stats); - expect(result).toEqual([ - ['Resolution', '1280x720'] - ]); + expect(result).toEqual([['Resolution', '1280x720']]); }); it('should prefer resolution over width/height', () => { const stats = { resolution: '1920x1080', width: 1280, - height: 720 + height: 720, }; const result = RecordingDetailsModalUtils.getStatRows(stats); - expect(result).toEqual([ - ['Resolution', '1920x1080'] - ]); + expect(result).toEqual([['Resolution', '1920x1080']]); }); it('should filter out null values', () => { const stats = { video_codec: 'H.264', resolution: null, - source_fps: 30 + source_fps: 30, }; const result = RecordingDetailsModalUtils.getStatRows(stats); expect(result).toEqual([ ['Video Codec', 'H.264'], - ['FPS', 30] + ['FPS', 30], ]); }); @@ -78,74 +74,68 @@ describe('RecordingDetailsModalUtils', () => { const stats = { video_codec: 'H.264', source_fps: undefined, - audio_codec: 'AAC' + audio_codec: 'AAC', }; const result = RecordingDetailsModalUtils.getStatRows(stats); expect(result).toEqual([ ['Video Codec', 'H.264'], - ['Audio Codec', 'AAC'] + ['Audio Codec', 'AAC'], ]); }); it('should filter out empty strings', () => { const stats = { video_codec: '', - audio_codec: 'AAC' + audio_codec: 'AAC', }; const result = RecordingDetailsModalUtils.getStatRows(stats); - expect(result).toEqual([ - ['Audio Codec', 'AAC'] - ]); + expect(result).toEqual([['Audio Codec', 'AAC']]); }); it('should handle missing width or height gracefully', () => { const stats = { width: 1920, - video_codec: 'H.264' + video_codec: 'H.264', }; const result = RecordingDetailsModalUtils.getStatRows(stats); - expect(result).toEqual([ - ['Video Codec', 'H.264'] - ]); + expect(result).toEqual([['Video Codec', 'H.264']]); }); it('should format bitrates correctly', () => { const stats = { video_bitrate: 2500, - audio_bitrate: 192 + audio_bitrate: 192, }; const result = RecordingDetailsModalUtils.getStatRows(stats); expect(result).toEqual([ ['Video Bitrate', '2500 kb/s'], - ['Audio Bitrate', '192 kb/s'] + ['Audio Bitrate', '192 kb/s'], ]); }); it('should format sample rate correctly', () => { const stats = { - sample_rate: 44100 + sample_rate: 44100, }; const result = RecordingDetailsModalUtils.getStatRows(stats); - expect(result).toEqual([ - ['Sample Rate', '44100 Hz'] - ]); + expect(result).toEqual([['Sample Rate', '44100 Hz']]); }); it('should return empty array when no valid stats', () => { const stats = { video_codec: null, resolution: undefined, - source_fps: '' + source_fps: '', }; const result = RecordingDetailsModalUtils.getStatRows(stats); @@ -193,7 +183,7 @@ describe('RecordingDetailsModalUtils', () => { it('should return rating from program custom_properties', () => { const customProps = {}; const program = { - custom_properties: { rating: 'TV-14' } + custom_properties: { rating: 'TV-14' }, }; const result = RecordingDetailsModalUtils.getRating(customProps, program); @@ -204,7 +194,7 @@ describe('RecordingDetailsModalUtils', () => { it('should prefer customProps rating over program rating', () => { const customProps = { rating: 'TV-MA' }; const program = { - custom_properties: { rating: 'TV-14' } + custom_properties: { rating: 'TV-14' }, }; const result = RecordingDetailsModalUtils.getRating(customProps, program); @@ -215,7 +205,7 @@ describe('RecordingDetailsModalUtils', () => { it('should prefer rating_value over program rating', () => { const customProps = { rating_value: 'PG-13' }; const program = { - custom_properties: { rating: 'TV-14' } + custom_properties: { rating: 'TV-14' }, }; const result = RecordingDetailsModalUtils.getRating(customProps, program); @@ -294,16 +284,16 @@ describe('RecordingDetailsModalUtils', () => { start_time: '2024-01-02T12:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show' } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, }, { start_time: '2024-01-02T13:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show2', title: 'Other Show' } - } - } + program: { tvg_id: 'show2', title: 'Other Show' }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -325,16 +315,16 @@ describe('RecordingDetailsModalUtils', () => { start_time: '2023-12-31T12:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show' } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, }, { start_time: '2024-01-02T12:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show' } - } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -358,8 +348,8 @@ describe('RecordingDetailsModalUtils', () => { custom_properties: { season: 1, episode: 5, - program: { tvg_id: 'show1', title: 'Test Show' } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, }, { start_time: '2024-01-02T18:00:00', @@ -367,9 +357,9 @@ describe('RecordingDetailsModalUtils', () => { custom_properties: { season: 1, episode: 5, - program: { tvg_id: 'show1', title: 'Test Show' } - } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -391,17 +381,17 @@ describe('RecordingDetailsModalUtils', () => { channel: 'ch1', custom_properties: { onscreen_episode: 'S01E05', - program: { tvg_id: 'show1', title: 'Test Show' } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, }, { start_time: '2024-01-02T18:00:00', channel: 'ch2', custom_properties: { onscreen_episode: 's01e05', - program: { tvg_id: 'show1', title: 'Test Show' } - } - } + program: { tvg_id: 'show1', title: 'Test Show' }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -425,9 +415,9 @@ describe('RecordingDetailsModalUtils', () => { program: { tvg_id: 'show1', title: 'Test Show', - sub_title: 'The Beginning' - } - } + sub_title: 'The Beginning', + }, + }, }, { start_time: '2024-01-02T18:00:00', @@ -436,10 +426,10 @@ describe('RecordingDetailsModalUtils', () => { program: { tvg_id: 'show1', title: 'Test Show', - sub_title: 'The Beginning' - } - } - } + sub_title: 'The Beginning', + }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -460,16 +450,16 @@ describe('RecordingDetailsModalUtils', () => { start_time: '2024-01-02T12:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show', id: 123 } - } + program: { tvg_id: 'show1', title: 'Test Show', id: 123 }, + }, }, { start_time: '2024-01-02T18:00:00', channel: 'ch2', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show', id: 123 } - } - } + program: { tvg_id: 'show1', title: 'Test Show', id: 123 }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -491,25 +481,25 @@ describe('RecordingDetailsModalUtils', () => { end_time: '2024-01-03T13:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show', id: 3 } - } + program: { tvg_id: 'show1', title: 'Test Show', id: 3 }, + }, }, { start_time: '2024-01-02T12:00:00', end_time: '2024-01-02T13:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show', id: 1 } - } + program: { tvg_id: 'show1', title: 'Test Show', id: 1 }, + }, }, { start_time: '2024-01-04T12:00:00', end_time: '2024-01-04T13:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show', id: 4 } - } - } + program: { tvg_id: 'show1', title: 'Test Show', id: 4 }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -533,9 +523,9 @@ describe('RecordingDetailsModalUtils', () => { start_time: '2024-01-02T12:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Test Show', id: 1 } - } - } + program: { tvg_id: 'show1', title: 'Test Show', id: 1 }, + }, + }, }; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -556,9 +546,9 @@ describe('RecordingDetailsModalUtils', () => { start_time: '2024-01-02T12:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'test show' } - } - } + program: { tvg_id: 'show1', title: 'test show' }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -582,9 +572,9 @@ describe('RecordingDetailsModalUtils', () => { program: { tvg_id: 'show1', title: 'Test Show', - custom_properties: { season: 2, episode: 3 } - } - } + custom_properties: { season: 2, episode: 3 }, + }, + }, }, { start_time: '2024-01-02T18:00:00', @@ -593,10 +583,10 @@ describe('RecordingDetailsModalUtils', () => { program: { tvg_id: 'show1', title: 'Test Show', - custom_properties: { season: 2, episode: 3 } - } - } - } + custom_properties: { season: 2, episode: 3 }, + }, + }, + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( @@ -615,8 +605,8 @@ describe('RecordingDetailsModalUtils', () => { const recordings = [ { start_time: '2024-01-02T12:00:00', - channel: 'ch1' - } + channel: 'ch1', + }, ]; const result = RecordingDetailsModalUtils.getUpcomingEpisodes( diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js index e2cb95fd..04561926 100644 --- a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js @@ -6,8 +6,8 @@ import dayjs from 'dayjs'; vi.mock('../../../api.js', () => ({ default: { updateRecurringRule: vi.fn(), - deleteRecurringRule: vi.fn() - } + deleteRecurringRule: vi.fn(), + }, })); describe('RecurringRuleModalUtils', () => { @@ -20,7 +20,7 @@ describe('RecurringRuleModalUtils', () => { const channels = { ch1: { id: 1, channel_number: '10', name: 'ABC' }, ch2: { id: 2, channel_number: '5', name: 'NBC' }, - ch3: { id: 3, channel_number: '15', name: 'CBS' } + ch3: { id: 3, channel_number: '15', name: 'CBS' }, }; const result = RecurringRuleModalUtils.getChannelOptions(channels); @@ -28,7 +28,7 @@ describe('RecurringRuleModalUtils', () => { expect(result).toEqual([ { value: '2', label: 'NBC' }, { value: '1', label: 'ABC' }, - { value: '3', label: 'CBS' } + { value: '3', label: 'CBS' }, ]); }); @@ -36,7 +36,7 @@ describe('RecurringRuleModalUtils', () => { const channels = { ch1: { id: 1, channel_number: '10', name: 'ZBC' }, ch2: { id: 2, channel_number: '10', name: 'ABC' }, - ch3: { id: 3, channel_number: '10', name: 'MBC' } + ch3: { id: 3, channel_number: '10', name: 'MBC' }, }; const result = RecurringRuleModalUtils.getChannelOptions(channels); @@ -44,35 +44,35 @@ describe('RecurringRuleModalUtils', () => { expect(result).toEqual([ { value: '2', label: 'ABC' }, { value: '3', label: 'MBC' }, - { value: '1', label: 'ZBC' } + { value: '1', label: 'ZBC' }, ]); }); it('should handle missing channel numbers', () => { const channels = { ch1: { id: 1, name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' } + ch2: { id: 2, channel_number: '5', name: 'NBC' }, }; const result = RecurringRuleModalUtils.getChannelOptions(channels); expect(result).toEqual([ { value: '1', label: 'ABC' }, - { value: '2', label: 'NBC' } + { value: '2', label: 'NBC' }, ]); }); it('should use fallback label when name is missing', () => { const channels = { ch1: { id: 1, channel_number: '10' }, - ch2: { id: 2, channel_number: '5', name: '' } + ch2: { id: 2, channel_number: '5', name: '' }, }; const result = RecurringRuleModalUtils.getChannelOptions(channels); expect(result).toEqual([ { value: '2', label: 'Channel 2' }, - { value: '1', label: 'Channel 1' } + { value: '1', label: 'Channel 1' }, ]); }); @@ -96,7 +96,7 @@ describe('RecurringRuleModalUtils', () => { it('should convert channel id to string value', () => { const channels = { - ch1: { id: 123, channel_number: '10', name: 'ABC' } + ch1: { id: 123, channel_number: '10', name: 'ABC' }, }; const result = RecurringRuleModalUtils.getChannelOptions(channels); @@ -108,7 +108,7 @@ describe('RecurringRuleModalUtils', () => { it('should handle non-numeric channel numbers', () => { const channels = { ch1: { id: 1, channel_number: 'HD1', name: 'ABC' }, - ch2: { id: 2, channel_number: '5', name: 'NBC' } + ch2: { id: 2, channel_number: '5', name: 'NBC' }, }; const result = RecurringRuleModalUtils.getChannelOptions(channels); @@ -131,16 +131,16 @@ describe('RecurringRuleModalUtils', () => { const recordings = [ { start_time: '2024-01-02T12:00:00', - custom_properties: { rule: { id: 1 } } + custom_properties: { rule: { id: 1 } }, }, { start_time: '2024-01-03T12:00:00', - custom_properties: { rule: { id: 1 } } + custom_properties: { rule: { id: 1 } }, }, { start_time: '2024-01-04T12:00:00', - custom_properties: { rule: { id: 2 } } - } + custom_properties: { rule: { id: 2 } }, + }, ]; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -159,12 +159,12 @@ describe('RecurringRuleModalUtils', () => { const recordings = [ { start_time: '2023-12-31T12:00:00', - custom_properties: { rule: { id: 1 } } + custom_properties: { rule: { id: 1 } }, }, { start_time: '2024-01-02T12:00:00', - custom_properties: { rule: { id: 1 } } - } + custom_properties: { rule: { id: 1 } }, + }, ]; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -182,16 +182,16 @@ describe('RecurringRuleModalUtils', () => { const recordings = [ { start_time: '2024-01-04T12:00:00', - custom_properties: { rule: { id: 1 } } + custom_properties: { rule: { id: 1 } }, }, { start_time: '2024-01-02T12:00:00', - custom_properties: { rule: { id: 1 } } + custom_properties: { rule: { id: 1 } }, }, { start_time: '2024-01-03T12:00:00', - custom_properties: { rule: { id: 1 } } - } + custom_properties: { rule: { id: 1 } }, + }, ]; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -211,12 +211,12 @@ describe('RecurringRuleModalUtils', () => { const recordings = { rec1: { start_time: '2024-01-02T12:00:00', - custom_properties: { rule: { id: 1 } } + custom_properties: { rule: { id: 1 } }, }, rec2: { start_time: '2024-01-03T12:00:00', - custom_properties: { rule: { id: 1 } } - } + custom_properties: { rule: { id: 1 } }, + }, }; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -254,8 +254,8 @@ describe('RecurringRuleModalUtils', () => { it('should handle recordings without custom_properties', () => { const recordings = [ { - start_time: '2024-01-02T12:00:00' - } + start_time: '2024-01-02T12:00:00', + }, ]; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -272,8 +272,8 @@ describe('RecurringRuleModalUtils', () => { const recordings = [ { start_time: '2024-01-02T12:00:00', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -290,8 +290,8 @@ describe('RecurringRuleModalUtils', () => { const recordings = [ { start_time: '2024-01-02T12:00:00', - custom_properties: { rule: null } - } + custom_properties: { rule: null }, + }, ]; const result = RecurringRuleModalUtils.getUpcomingOccurrences( @@ -315,7 +315,7 @@ describe('RecurringRuleModalUtils', () => { start_date: '2024-01-01', end_date: '2024-12-31', rule_name: 'My Rule', - enabled: true + enabled: true, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -328,7 +328,7 @@ describe('RecurringRuleModalUtils', () => { start_date: '2024-01-01', end_date: '2024-12-31', name: 'My Rule', - enabled: true + enabled: true, }); }); @@ -338,7 +338,7 @@ describe('RecurringRuleModalUtils', () => { days_of_week: ['0', '6'], start_time: '10:00', end_time: '11:00', - enabled: false + enabled: false, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -351,7 +351,7 @@ describe('RecurringRuleModalUtils', () => { start_date: null, end_date: null, name: '', - enabled: false + enabled: false, }); }); @@ -360,7 +360,7 @@ describe('RecurringRuleModalUtils', () => { channel_id: '5', start_time: '10:00', end_time: '11:00', - enabled: true + enabled: true, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -373,7 +373,7 @@ describe('RecurringRuleModalUtils', () => { start_date: null, end_date: null, name: '', - enabled: true + enabled: true, }); }); @@ -385,7 +385,7 @@ describe('RecurringRuleModalUtils', () => { end_time: '11:00', start_date: dayjs('2024-06-15'), end_date: dayjs('2024-12-25'), - enabled: true + enabled: true, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -398,7 +398,7 @@ describe('RecurringRuleModalUtils', () => { start_date: '2024-06-15', end_date: '2024-12-25', name: '', - enabled: true + enabled: true, }); }); @@ -410,7 +410,7 @@ describe('RecurringRuleModalUtils', () => { end_time: '11:00', start_date: null, end_date: null, - enabled: true + enabled: true, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -423,7 +423,7 @@ describe('RecurringRuleModalUtils', () => { start_date: null, end_date: null, name: '', - enabled: true + enabled: true, }); }); @@ -434,7 +434,7 @@ describe('RecurringRuleModalUtils', () => { start_time: '10:00', end_time: '11:00', rule_name: ' Trimmed Name ', - enabled: true + enabled: true, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -447,7 +447,7 @@ describe('RecurringRuleModalUtils', () => { start_date: null, end_date: null, name: 'Trimmed Name', - enabled: true + enabled: true, }); }); @@ -457,7 +457,7 @@ describe('RecurringRuleModalUtils', () => { days_of_week: [], start_time: '10:00', end_time: '11:00', - enabled: true + enabled: true, }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -470,7 +470,7 @@ describe('RecurringRuleModalUtils', () => { start_date: null, end_date: null, name: '', - enabled: true + enabled: true, }); }); @@ -480,7 +480,7 @@ describe('RecurringRuleModalUtils', () => { days_of_week: [], start_time: '10:00', end_time: '11:00', - enabled: 'true' + enabled: 'true', }; await RecurringRuleModalUtils.updateRecurringRule(1, values); @@ -493,7 +493,7 @@ describe('RecurringRuleModalUtils', () => { start_date: null, end_date: null, name: '', - enabled: true + enabled: true, }); }); }); @@ -518,7 +518,7 @@ describe('RecurringRuleModalUtils', () => { await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, true); expect(API.updateRecurringRule).toHaveBeenCalledWith(1, { - enabled: true + enabled: true, }); }); @@ -526,7 +526,7 @@ describe('RecurringRuleModalUtils', () => { await RecurringRuleModalUtils.updateRecurringRuleEnabled(1, false); expect(API.updateRecurringRule).toHaveBeenCalledWith(1, { - enabled: false + enabled: false, }); }); }); diff --git a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js index bbb1085a..fcc5a4c0 100644 --- a/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/DvrSettingsFormUtils.js @@ -10,13 +10,13 @@ export const uploadComskipIni = async (file) => { export const getDvrSettingsFormInitialValues = () => { return { - 'tv_template': '', - 'movie_template': '', - 'tv_fallback_template': '', - 'movie_fallback_template': '', - 'comskip_enabled': false, - 'comskip_custom_path': '', - 'pre_offset_minutes': 0, - 'post_offset_minutes': 0, + tv_template: '', + movie_template: '', + tv_fallback_template: '', + movie_fallback_template: '', + comskip_enabled: false, + comskip_custom_path: '', + pre_offset_minutes: 0, + post_offset_minutes: 0, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js index eaa535fb..1a871571 100644 --- a/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js +++ b/frontend/src/utils/forms/settings/NetworkAccessFormUtils.js @@ -2,7 +2,8 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js'; import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js'; // Default CIDR ranges for M3U/EPG endpoints (local networks only) -const M3U_EPG_DEFAULTS = '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10'; +const M3U_EPG_DEFAULTS = + '127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10'; export const getNetworkAccessFormInitialValues = () => { return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => { @@ -39,4 +40,4 @@ export const getNetworkAccessDefaults = () => { XC_API: '0.0.0.0/0,::/0', UI: '0.0.0.0/0,::/0', }; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js index 864dd9b1..9ca185e8 100644 --- a/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/ProxySettingsFormUtils.js @@ -15,4 +15,4 @@ export const getProxySettingDefaults = () => { channel_shutdown_delay: 0, channel_init_grace_period: 5, }; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js index db91480c..b1f356d9 100644 --- a/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/StreamSettingsFormUtils.js @@ -16,4 +16,4 @@ export const getStreamSettingsFormValidation = () => { default_stream_profile: isNotEmpty('Select a stream profile'), preferred_region: isNotEmpty('Select a region'), }; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js index 9d67039e..0830c70c 100644 --- a/frontend/src/utils/forms/settings/UiSettingsFormUtils.js +++ b/frontend/src/utils/forms/settings/UiSettingsFormUtils.js @@ -14,4 +14,4 @@ export const saveTimeZoneSetting = async (tzValue, settings) => { value: newValue, }); } -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js index 49a43eb1..35bb6501 100644 --- a/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js @@ -13,7 +13,7 @@ describe('DvrSettingsFormUtils', () => { it('should call API.getComskipConfig and return result', async () => { const mockConfig = { enabled: true, - custom_path: '/path/to/comskip' + custom_path: '/path/to/comskip', }; API.getComskipConfig.mockResolvedValue(mockConfig); @@ -27,13 +27,17 @@ describe('DvrSettingsFormUtils', () => { const error = new Error('API Error'); API.getComskipConfig.mockRejectedValue(error); - await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow('API Error'); + await expect(DvrSettingsFormUtils.getComskipConfig()).rejects.toThrow( + 'API Error' + ); }); }); describe('uploadComskipIni', () => { it('should call API.uploadComskipIni with file and return result', async () => { - const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + const mockFile = new File(['content'], 'comskip.ini', { + type: 'text/plain', + }); const mockResponse = { success: true }; API.uploadComskipIni.mockResolvedValue(mockResponse); @@ -44,11 +48,15 @@ describe('DvrSettingsFormUtils', () => { }); it('should handle API errors', async () => { - const mockFile = new File(['content'], 'comskip.ini', { type: 'text/plain' }); + const mockFile = new File(['content'], 'comskip.ini', { + type: 'text/plain', + }); const error = new Error('Upload failed'); API.uploadComskipIni.mockRejectedValue(error); - await expect(DvrSettingsFormUtils.uploadComskipIni(mockFile)).rejects.toThrow('Upload failed'); + await expect( + DvrSettingsFormUtils.uploadComskipIni(mockFile) + ).rejects.toThrow('Upload failed'); }); }); @@ -57,14 +65,14 @@ describe('DvrSettingsFormUtils', () => { const result = DvrSettingsFormUtils.getDvrSettingsFormInitialValues(); expect(result).toEqual({ - 'tv_template': '', - 'movie_template': '', - 'tv_fallback_template': '', - 'movie_fallback_template': '', - 'comskip_enabled': false, - 'comskip_custom_path': '', - 'pre_offset_minutes': 0, - 'post_offset_minutes': 0, + tv_template: '', + movie_template: '', + tv_fallback_template: '', + movie_fallback_template: '', + comskip_enabled: false, + comskip_custom_path: '', + pre_offset_minutes: 0, + post_offset_minutes: 0, }); }); diff --git a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js index d924b430..11438d00 100644 --- a/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js @@ -3,12 +3,12 @@ import * as NetworkAccessFormUtils from '../NetworkAccessFormUtils'; import * as constants from '../../../../constants.js'; vi.mock('../../../../constants.js', () => ({ - NETWORK_ACCESS_OPTIONS: {} + NETWORK_ACCESS_OPTIONS: {}, })); vi.mock('../../../networkUtils.js', () => ({ IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/, - IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/ + IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/, })); describe('NetworkAccessFormUtils', () => { @@ -21,7 +21,7 @@ describe('NetworkAccessFormUtils', () => { vi.mocked(constants).NETWORK_ACCESS_OPTIONS = { 'network-access-admin': 'Admin Access', 'network-access-api': 'API Access', - 'network-access-streaming': 'Streaming Access' + 'network-access-streaming': 'Streaming Access', }; const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues(); @@ -29,7 +29,7 @@ describe('NetworkAccessFormUtils', () => { expect(result).toEqual({ 'network-access-admin': '0.0.0.0/0,::/0', 'network-access-api': '0.0.0.0/0,::/0', - 'network-access-streaming': '0.0.0.0/0,::/0' + 'network-access-streaming': '0.0.0.0/0,::/0', }); }); @@ -43,11 +43,13 @@ describe('NetworkAccessFormUtils', () => { it('should return a new object each time', () => { vi.mocked(constants).NETWORK_ACCESS_OPTIONS = { - 'network-access-admin': 'Admin Access' + 'network-access-admin': 'Admin Access', }; - const result1 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues(); - const result2 = NetworkAccessFormUtils.getNetworkAccessFormInitialValues(); + const result1 = + NetworkAccessFormUtils.getNetworkAccessFormInitialValues(); + const result2 = + NetworkAccessFormUtils.getNetworkAccessFormInitialValues(); expect(result1).toEqual(result2); expect(result1).not.toBe(result2); @@ -58,20 +60,24 @@ describe('NetworkAccessFormUtils', () => { beforeEach(() => { vi.mocked(constants).NETWORK_ACCESS_OPTIONS = { 'network-access-admin': 'Admin Access', - 'network-access-api': 'API Access' + 'network-access-api': 'API Access', }; }); it('should return validation functions for all network access options', () => { const result = NetworkAccessFormUtils.getNetworkAccessFormValidation(); - expect(Object.keys(result)).toEqual(['network-access-admin', 'network-access-api']); + expect(Object.keys(result)).toEqual([ + 'network-access-admin', + 'network-access-api', + ]); expect(typeof result['network-access-admin']).toBe('function'); expect(typeof result['network-access-api']).toBe('function'); }); it('should validate valid IPv4 CIDR ranges', () => { - const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); + const validation = + NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; expect(validator('192.168.1.0/24')).toBeNull(); @@ -80,7 +86,8 @@ describe('NetworkAccessFormUtils', () => { }); it('should validate valid IPv6 CIDR ranges', () => { - const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); + const validation = + NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; expect(validator('2001:db8::/32')).toBeNull(); @@ -88,7 +95,8 @@ describe('NetworkAccessFormUtils', () => { }); it('should validate multiple CIDR ranges separated by commas', () => { - const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); + const validation = + NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull(); @@ -97,7 +105,8 @@ describe('NetworkAccessFormUtils', () => { }); it('should return error for invalid IPv4 CIDR ranges', () => { - const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); + const validation = + NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range'); @@ -106,16 +115,20 @@ describe('NetworkAccessFormUtils', () => { }); it('should return error when any CIDR in comma-separated list is invalid', () => { - const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); + const validation = + NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range'); expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range'); - expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe('Invalid CIDR range'); + expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe( + 'Invalid CIDR range' + ); }); it('should handle empty strings', () => { - const validation = NetworkAccessFormUtils.getNetworkAccessFormValidation(); + const validation = + NetworkAccessFormUtils.getNetworkAccessFormValidation(); const validator = validation['network-access-admin']; expect(validator('')).toBe('Invalid CIDR range'); diff --git a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js index d6fe3008..8feefccb 100644 --- a/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js @@ -3,7 +3,7 @@ import * as ProxySettingsFormUtils from '../ProxySettingsFormUtils'; import * as constants from '../../../../constants.js'; vi.mock('../../../../constants.js', () => ({ - PROXY_SETTINGS_OPTIONS: {} + PROXY_SETTINGS_OPTIONS: {}, })); describe('ProxySettingsFormUtils', () => { @@ -16,7 +16,7 @@ describe('ProxySettingsFormUtils', () => { vi.mocked(constants).PROXY_SETTINGS_OPTIONS = { 'proxy-buffering-timeout': 'Buffering Timeout', 'proxy-buffering-speed': 'Buffering Speed', - 'proxy-redis-chunk-ttl': 'Redis Chunk TTL' + 'proxy-redis-chunk-ttl': 'Redis Chunk TTL', }; const result = ProxySettingsFormUtils.getProxySettingsFormInitialValues(); @@ -24,7 +24,7 @@ describe('ProxySettingsFormUtils', () => { expect(result).toEqual({ 'proxy-buffering-timeout': '', 'proxy-buffering-speed': '', - 'proxy-redis-chunk-ttl': '' + 'proxy-redis-chunk-ttl': '', }); }); @@ -38,11 +38,13 @@ describe('ProxySettingsFormUtils', () => { it('should return a new object each time', () => { vi.mocked(constants).PROXY_SETTINGS_OPTIONS = { - 'proxy-setting': 'Proxy Setting' + 'proxy-setting': 'Proxy Setting', }; - const result1 = ProxySettingsFormUtils.getProxySettingsFormInitialValues(); - const result2 = ProxySettingsFormUtils.getProxySettingsFormInitialValues(); + const result1 = + ProxySettingsFormUtils.getProxySettingsFormInitialValues(); + const result2 = + ProxySettingsFormUtils.getProxySettingsFormInitialValues(); expect(result1).toEqual(result2); expect(result1).not.toBe(result2); diff --git a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js index 9cf87c9a..2ef36bb8 100644 --- a/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js @@ -3,7 +3,7 @@ import * as StreamSettingsFormUtils from '../StreamSettingsFormUtils'; import { isNotEmpty } from '@mantine/form'; vi.mock('@mantine/form', () => ({ - isNotEmpty: vi.fn((message) => message) + isNotEmpty: vi.fn((message) => message), })); describe('StreamSettingsFormUtils', () => { @@ -13,42 +13,49 @@ describe('StreamSettingsFormUtils', () => { describe('getStreamSettingsFormInitialValues', () => { it('should return initial values with correct defaults', () => { - const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); expect(result).toEqual({ - 'default_user_agent': '', - 'default_stream_profile': '', - 'preferred_region': '', - 'auto_import_mapped_files': true, - 'm3u_hash_key': [] + default_user_agent: '', + default_stream_profile: '', + preferred_region: '', + auto_import_mapped_files: true, + m3u_hash_key: [], }); }); it('should return boolean true for auto-import-mapped-files', () => { - const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); expect(result['auto_import_mapped_files']).toBe(true); expect(typeof result['auto_import_mapped_files']).toBe('boolean'); }); it('should return empty array for m3u-hash-key', () => { - const result = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); expect(result['m3u_hash_key']).toEqual([]); expect(Array.isArray(result['m3u_hash_key'])).toBe(true); }); it('should return a new object each time', () => { - const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); - const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result1 = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result2 = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); expect(result1).toEqual(result2); expect(result1).not.toBe(result2); }); it('should return a new array instance for m3u-hash-key each time', () => { - const result1 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); - const result2 = StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result1 = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); + const result2 = + StreamSettingsFormUtils.getStreamSettingsFormInitialValues(); expect(result1['m3u_hash_key']).not.toBe(result2['m3u_hash_key']); }); @@ -61,7 +68,7 @@ describe('StreamSettingsFormUtils', () => { expect(Object.keys(result)).toEqual([ 'default_user_agent', 'default_stream_profile', - 'preferred_region' + 'preferred_region', ]); }); diff --git a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js index 1bed3529..4f48bd16 100644 --- a/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js @@ -4,30 +4,35 @@ import * as SystemSettingsFormUtils from '../SystemSettingsFormUtils'; describe('SystemSettingsFormUtils', () => { describe('getSystemSettingsFormInitialValues', () => { it('should return initial values with correct defaults', () => { - const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); + const result = + SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); expect(result).toEqual({ - 'max_system_events': 100 + max_system_events: 100, }); }); it('should return number value for max-system-events', () => { - const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); + const result = + SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); expect(result['max_system_events']).toBe(100); expect(typeof result['max_system_events']).toBe('number'); }); it('should return a new object each time', () => { - const result1 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); - const result2 = SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); + const result1 = + SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); + const result2 = + SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); expect(result1).toEqual(result2); expect(result1).not.toBe(result2); }); it('should have max-system-events property', () => { - const result = SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); + const result = + SystemSettingsFormUtils.getSystemSettingsFormInitialValues(); expect(result).toHaveProperty('max_system_events'); }); diff --git a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js index add9dc3a..83edb9f5 100644 --- a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js @@ -4,7 +4,7 @@ import * as SettingsUtils from '../../../pages/SettingsUtils.js'; vi.mock('../../../pages/SettingsUtils.js', () => ({ createSetting: vi.fn(), - updateSetting: vi.fn() + updateSetting: vi.fn(), })); describe('UiSettingsFormUtils', () => { @@ -16,12 +16,12 @@ describe('UiSettingsFormUtils', () => { it('should update existing setting when id is present', async () => { const tzValue = 'America/New_York'; const settings = { - 'system_settings': { + system_settings: { id: 123, key: 'system_settings', name: 'System Settings', - value: { time_zone: 'UTC' } - } + value: { time_zone: 'UTC' }, + }, }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); @@ -31,7 +31,7 @@ describe('UiSettingsFormUtils', () => { id: 123, key: 'system_settings', name: 'System Settings', - value: { time_zone: 'America/New_York' } + value: { time_zone: 'America/New_York' }, }); expect(SettingsUtils.createSetting).not.toHaveBeenCalled(); }); @@ -39,11 +39,11 @@ describe('UiSettingsFormUtils', () => { it('should create new setting when existing setting has no id', async () => { const tzValue = 'Europe/London'; const settings = { - 'system_settings': { + system_settings: { key: 'system_settings', name: 'System Settings', - value: { time_zone: 'UTC' } - } + value: { time_zone: 'UTC' }, + }, }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); @@ -52,7 +52,7 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.createSetting).toHaveBeenCalledWith({ key: 'system_settings', name: 'System Settings', - value: { time_zone: 'Europe/London' } + value: { time_zone: 'Europe/London' }, }); expect(SettingsUtils.updateSetting).not.toHaveBeenCalled(); }); @@ -67,7 +67,7 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.createSetting).toHaveBeenCalledWith({ key: 'system_settings', name: 'System Settings', - value: { time_zone: 'Asia/Tokyo' } + value: { time_zone: 'Asia/Tokyo' }, }); expect(SettingsUtils.updateSetting).not.toHaveBeenCalled(); }); @@ -75,7 +75,7 @@ describe('UiSettingsFormUtils', () => { it('should create new setting when system_settings is null', async () => { const tzValue = 'Pacific/Auckland'; const settings = { - 'system_settings': null + system_settings: null, }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); @@ -84,7 +84,7 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.createSetting).toHaveBeenCalledWith({ key: 'system_settings', name: 'System Settings', - value: { time_zone: 'Pacific/Auckland' } + value: { time_zone: 'Pacific/Auckland' }, }); expect(SettingsUtils.updateSetting).not.toHaveBeenCalled(); }); @@ -92,11 +92,11 @@ describe('UiSettingsFormUtils', () => { it('should create new setting when id is undefined', async () => { const tzValue = 'America/Los_Angeles'; const settings = { - 'system_settings': { + system_settings: { id: undefined, key: 'system_settings', - value: { time_zone: 'UTC' } - } + value: { time_zone: 'UTC' }, + }, }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); @@ -108,13 +108,13 @@ describe('UiSettingsFormUtils', () => { it('should preserve existing properties when updating', async () => { const tzValue = 'UTC'; const settings = { - 'system_settings': { + system_settings: { id: 456, key: 'system_settings', name: 'System Settings', value: { time_zone: 'America/New_York', some_other_setting: 'value' }, - extraProp: 'should be preserved' - } + extraProp: 'should be preserved', + }, }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); @@ -124,19 +124,19 @@ describe('UiSettingsFormUtils', () => { key: 'system_settings', name: 'System Settings', value: { time_zone: 'UTC', some_other_setting: 'value' }, - extraProp: 'should be preserved' + extraProp: 'should be preserved', }); }); it('should handle empty string timezone value', async () => { const tzValue = ''; const settings = { - 'system_settings': { + system_settings: { id: 789, key: 'system_settings', name: 'System Settings', - value: { time_zone: 'America/New_York' } - } + value: { time_zone: 'America/New_York' }, + }, }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); @@ -145,7 +145,7 @@ describe('UiSettingsFormUtils', () => { id: 789, key: 'system_settings', name: 'System Settings', - value: { time_zone: '' } + value: { time_zone: '' }, }); }); }); diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js index 8efd2254..920ab1c2 100644 --- a/frontend/src/utils/networkUtils.js +++ b/frontend/src/utils/networkUtils.js @@ -1,5 +1,6 @@ // IPv4 CIDR regex - validates IP address and prefix length (0-32) -export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/; +export const IPV4_CIDR_REGEX = + /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/; // IPv6 CIDR regex - validates IPv6 address and prefix length (0-128) export const IPV6_CIDR_REGEX = @@ -21,4 +22,4 @@ export function formatSpeed(bytes) { const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; -} \ No newline at end of file +} diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js index ba965343..1456130a 100644 --- a/frontend/src/utils/notificationUtils.js +++ b/frontend/src/utils/notificationUtils.js @@ -6,4 +6,4 @@ export function showNotification(notificationObject) { export function updateNotification(notificationId, notificationObject) { return notifications.update(notificationId, notificationObject); -} \ No newline at end of file +} diff --git a/frontend/src/utils/pages/DVRUtils.js b/frontend/src/utils/pages/DVRUtils.js index 139988d2..bcb200b2 100644 --- a/frontend/src/utils/pages/DVRUtils.js +++ b/frontend/src/utils/pages/DVRUtils.js @@ -40,7 +40,7 @@ const dedupeById = (list, toUserTime, completed, now, inProgress, upcoming) => { else completed.push(rec); } } -} +}; export const categorizeRecordings = (recordings, toUserTime, now) => { const inProgress = []; @@ -87,4 +87,4 @@ export const categorizeRecordings = (recordings, toUserTime, now) => { upcoming: upcomingGrouped, completed, }; -} \ No newline at end of file +}; diff --git a/frontend/src/utils/pages/SettingsUtils.js b/frontend/src/utils/pages/SettingsUtils.js index c3bb9caf..cafe0d8c 100644 --- a/frontend/src/utils/pages/SettingsUtils.js +++ b/frontend/src/utils/pages/SettingsUtils.js @@ -27,12 +27,39 @@ export const saveChangedSettings = async (settings, changedSettings) => { }; // Map of field prefixes to their groups - const streamFields = ['default_user_agent', 'default_stream_profile', 'm3u_hash_key', 'preferred_region', 'auto_import_mapped_files']; - const epgFields = ['epg_match_mode', 'epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom']; - const dvrFields = ['tv_template', 'movie_template', 'tv_fallback_dir', 'tv_fallback_template', 'movie_fallback_template', - 'comskip_enabled', 'comskip_custom_path', 'pre_offset_minutes', 'post_offset_minutes', 'series_rules']; - const backupFields = ['schedule_enabled', 'schedule_frequency', 'schedule_time', 'schedule_day_of_week', - 'retention_count', 'schedule_cron_expression']; + const streamFields = [ + 'default_user_agent', + 'default_stream_profile', + 'm3u_hash_key', + 'preferred_region', + 'auto_import_mapped_files', + ]; + const epgFields = [ + 'epg_match_mode', + 'epg_match_ignore_prefixes', + 'epg_match_ignore_suffixes', + 'epg_match_ignore_custom', + ]; + const dvrFields = [ + 'tv_template', + 'movie_template', + 'tv_fallback_dir', + 'tv_fallback_template', + 'movie_fallback_template', + 'comskip_enabled', + 'comskip_custom_path', + 'pre_offset_minutes', + 'post_offset_minutes', + 'series_rules', + ]; + const backupFields = [ + 'schedule_enabled', + 'schedule_frequency', + 'schedule_time', + 'schedule_day_of_week', + 'retention_count', + 'schedule_cron_expression', + ]; const systemFields = ['time_zone', 'max_system_events']; for (const formKey in changedSettings) { @@ -44,7 +71,11 @@ export const saveChangedSettings = async (settings, changedSettings) => { if (existing?.id) { await updateSetting({ ...existing, value }); } else { - await createSetting({ key: 'proxy_settings', name: 'Proxy Settings', value }); + await createSetting({ + key: 'proxy_settings', + name: 'Proxy Settings', + value, + }); } continue; } @@ -54,7 +85,11 @@ export const saveChangedSettings = async (settings, changedSettings) => { if (existing?.id) { await updateSetting({ ...existing, value }); } else { - await createSetting({ key: 'network_access', name: 'Network Access', value }); + await createSetting({ + key: 'network_access', + name: 'Network Access', + value, + }); } continue; } @@ -65,16 +100,29 @@ export const saveChangedSettings = async (settings, changedSettings) => { value = value.join(','); } - if (['default_user_agent', 'default_stream_profile'].includes(formKey) && value != null) { + if ( + ['default_user_agent', 'default_stream_profile'].includes(formKey) && + value != null + ) { value = parseInt(value, 10); } - const numericFields = ['pre_offset_minutes', 'post_offset_minutes', 'retention_count', 'schedule_day_of_week', 'max_system_events']; + const numericFields = [ + 'pre_offset_minutes', + 'post_offset_minutes', + 'retention_count', + 'schedule_day_of_week', + 'max_system_events', + ]; if (numericFields.includes(formKey) && value != null) { value = typeof value === 'number' ? value : parseInt(value, 10); } - const booleanFields = ['comskip_enabled', 'schedule_enabled', 'auto_import_mapped_files']; + const booleanFields = [ + 'comskip_enabled', + 'schedule_enabled', + 'auto_import_mapped_files', + ]; if (booleanFields.includes(formKey) && value != null) { value = typeof value === 'boolean' ? value : Boolean(value); } @@ -107,8 +155,15 @@ export const saveChangedSettings = async (settings, changedSettings) => { throw new Error(`Failed to update ${groupKey}`); } } else { - const name = groupKey.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); - const result = await createSetting({ key: groupKey, name: name, value: newValue }); + const name = groupKey + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); + const result = await createSetting({ + key: groupKey, + name: name, + value: newValue, + }); if (!result) { throw new Error(`Failed to create ${groupKey}`); } @@ -120,7 +175,11 @@ export const getChangedSettings = (values, settings) => { const changedSettings = {}; // EPG fields that should be kept as arrays - const epgFields = ['epg_match_ignore_prefixes', 'epg_match_ignore_suffixes', 'epg_match_ignore_custom']; + const epgFields = [ + 'epg_match_ignore_prefixes', + 'epg_match_ignore_suffixes', + 'epg_match_ignore_custom', + ]; for (const settingKey in values) { // Skip grouped settings that are handled by their own dedicated forms @@ -181,8 +240,14 @@ export const parseSettings = (settings) => { const streamSettings = settings['stream_settings']?.value; if (streamSettings && typeof streamSettings === 'object') { // IDs must be strings for Select components - parsed.default_user_agent = streamSettings.default_user_agent != null ? String(streamSettings.default_user_agent) : null; - parsed.default_stream_profile = streamSettings.default_stream_profile != null ? String(streamSettings.default_stream_profile) : null; + parsed.default_user_agent = + streamSettings.default_user_agent != null + ? String(streamSettings.default_user_agent) + : null; + parsed.default_stream_profile = + streamSettings.default_stream_profile != null + ? String(streamSettings.default_stream_profile) + : null; parsed.preferred_region = streamSettings.preferred_region; parsed.auto_import_mapped_files = streamSettings.auto_import_mapped_files; @@ -200,9 +265,18 @@ export const parseSettings = (settings) => { // EPG settings - direct mapping with underscore keys const epgSettings = settings['epg_settings']?.value; // Always set EPG fields (even if settings don't exist yet) - parsed.epg_match_ignore_prefixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes)) ? epgSettings.epg_match_ignore_prefixes : []; - parsed.epg_match_ignore_suffixes = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes)) ? epgSettings.epg_match_ignore_suffixes : []; - parsed.epg_match_ignore_custom = (epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom)) ? epgSettings.epg_match_ignore_custom : []; + parsed.epg_match_ignore_prefixes = + epgSettings && Array.isArray(epgSettings.epg_match_ignore_prefixes) + ? epgSettings.epg_match_ignore_prefixes + : []; + parsed.epg_match_ignore_suffixes = + epgSettings && Array.isArray(epgSettings.epg_match_ignore_suffixes) + ? epgSettings.epg_match_ignore_suffixes + : []; + parsed.epg_match_ignore_custom = + epgSettings && Array.isArray(epgSettings.epg_match_ignore_custom) + ? epgSettings.epg_match_ignore_custom + : []; // DVR settings - direct mapping with underscore keys const dvrSettings = settings['dvr_settings']?.value; @@ -212,29 +286,52 @@ export const parseSettings = (settings) => { parsed.tv_fallback_dir = dvrSettings.tv_fallback_dir; parsed.tv_fallback_template = dvrSettings.tv_fallback_template; parsed.movie_fallback_template = dvrSettings.movie_fallback_template; - parsed.comskip_enabled = typeof dvrSettings.comskip_enabled === 'boolean' ? dvrSettings.comskip_enabled : Boolean(dvrSettings.comskip_enabled); + parsed.comskip_enabled = + typeof dvrSettings.comskip_enabled === 'boolean' + ? dvrSettings.comskip_enabled + : Boolean(dvrSettings.comskip_enabled); parsed.comskip_custom_path = dvrSettings.comskip_custom_path; - parsed.pre_offset_minutes = typeof dvrSettings.pre_offset_minutes === 'number' ? dvrSettings.pre_offset_minutes : parseInt(dvrSettings.pre_offset_minutes, 10) || 0; - parsed.post_offset_minutes = typeof dvrSettings.post_offset_minutes === 'number' ? dvrSettings.post_offset_minutes : parseInt(dvrSettings.post_offset_minutes, 10) || 0; + parsed.pre_offset_minutes = + typeof dvrSettings.pre_offset_minutes === 'number' + ? dvrSettings.pre_offset_minutes + : parseInt(dvrSettings.pre_offset_minutes, 10) || 0; + parsed.post_offset_minutes = + typeof dvrSettings.post_offset_minutes === 'number' + ? dvrSettings.post_offset_minutes + : parseInt(dvrSettings.post_offset_minutes, 10) || 0; parsed.series_rules = dvrSettings.series_rules; } // Backup settings - direct mapping with underscore keys const backupSettings = settings['backup_settings']?.value; if (backupSettings && typeof backupSettings === 'object') { - parsed.schedule_enabled = typeof backupSettings.schedule_enabled === 'boolean' ? backupSettings.schedule_enabled : Boolean(backupSettings.schedule_enabled); + parsed.schedule_enabled = + typeof backupSettings.schedule_enabled === 'boolean' + ? backupSettings.schedule_enabled + : Boolean(backupSettings.schedule_enabled); parsed.schedule_frequency = String(backupSettings.schedule_frequency || ''); parsed.schedule_time = String(backupSettings.schedule_time || ''); - parsed.schedule_day_of_week = typeof backupSettings.schedule_day_of_week === 'number' ? backupSettings.schedule_day_of_week : parseInt(backupSettings.schedule_day_of_week, 10) || 0; - parsed.retention_count = typeof backupSettings.retention_count === 'number' ? backupSettings.retention_count : parseInt(backupSettings.retention_count, 10) || 0; - parsed.schedule_cron_expression = String(backupSettings.schedule_cron_expression || ''); + parsed.schedule_day_of_week = + typeof backupSettings.schedule_day_of_week === 'number' + ? backupSettings.schedule_day_of_week + : parseInt(backupSettings.schedule_day_of_week, 10) || 0; + parsed.retention_count = + typeof backupSettings.retention_count === 'number' + ? backupSettings.retention_count + : parseInt(backupSettings.retention_count, 10) || 0; + parsed.schedule_cron_expression = String( + backupSettings.schedule_cron_expression || '' + ); } // System settings - direct mapping with underscore keys const systemSettings = settings['system_settings']?.value; if (systemSettings && typeof systemSettings === 'object') { parsed.time_zone = String(systemSettings.time_zone || ''); - parsed.max_system_events = typeof systemSettings.max_system_events === 'number' ? systemSettings.max_system_events : parseInt(systemSettings.max_system_events, 10) || 100; + parsed.max_system_events = + typeof systemSettings.max_system_events === 'number' + ? systemSettings.max_system_events + : parseInt(systemSettings.max_system_events, 10) || 100; } // Proxy and network access are already grouped objects @@ -246,4 +343,4 @@ export const parseSettings = (settings) => { } return parsed; -}; \ No newline at end of file +}; diff --git a/frontend/src/utils/pages/StatsUtils.js b/frontend/src/utils/pages/StatsUtils.js index 72198122..05f8c393 100644 --- a/frontend/src/utils/pages/StatsUtils.js +++ b/frontend/src/utils/pages/StatsUtils.js @@ -24,9 +24,11 @@ export const getCurrentPrograms = async (channelHistory, channelsByUUID) => { try { // Get all active channel IDs that have actual channels (not just streams) const activeChannelIds = Object.values(channelHistory) - .filter(ch => ch.name && channelsByUUID && channelsByUUID[ch.channel_id]) - .map(ch => channelsByUUID[ch.channel_id]) - .filter(id => id !== undefined); + .filter( + (ch) => ch.name && channelsByUUID && channelsByUUID[ch.channel_id] + ) + .map((ch) => channelsByUUID[ch.channel_id]) + .filter((id) => id !== undefined); if (activeChannelIds.length === 0) { return {}; @@ -37,7 +39,7 @@ export const getCurrentPrograms = async (channelHistory, channelsByUUID) => { // Convert array to map keyed by channel UUID for easy lookup const programsMap = {}; if (programs && Array.isArray(programs)) { - programs.forEach(program => { + programs.forEach((program) => { // Find the channel UUID from the channel ID const channelEntry = Object.entries(channelsByUUID).find( ([uuid, id]) => id === program.channel_id diff --git a/frontend/src/utils/pages/__tests__/DVRUtils.test.js b/frontend/src/utils/pages/__tests__/DVRUtils.test.js index 9c5bb15f..dd76784a 100644 --- a/frontend/src/utils/pages/__tests__/DVRUtils.test.js +++ b/frontend/src/utils/pages/__tests__/DVRUtils.test.js @@ -20,8 +20,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T11:00:00', end_time: '2024-01-01T13:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -39,8 +39,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -58,8 +58,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T10:00:00', end_time: '2024-01-01T11:00:00', channel: 'ch1', - custom_properties: { status: 'completed' } - } + custom_properties: { status: 'completed' }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -77,8 +77,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T11:00:00', end_time: '2024-01-01T13:00:00', channel: 'ch1', - custom_properties: { status: 'interrupted' } - } + custom_properties: { status: 'interrupted' }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -95,8 +95,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T09:00:00', end_time: '2024-01-01T10:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -114,8 +114,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T13:00:00', channel: 'ch1', custom_properties: { - program: { id: 100 } - } + program: { id: 100 }, + }, }, { id: 2, @@ -123,9 +123,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T13:00:00', channel: 'ch2', custom_properties: { - program: { id: 100 } - } - } + program: { id: 100 }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -141,8 +141,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T13:00:00', channel: 'ch1', custom_properties: { - program: { title: 'Show A' } - } + program: { title: 'Show A' }, + }, }, { id: 2, @@ -150,9 +150,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T13:00:00', channel: 'ch1', custom_properties: { - program: { title: 'Show A' } - } - } + program: { title: 'Show A' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -168,8 +168,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T13:00:00', channel: 'ch1', custom_properties: { - program: { title: 'Show A' } - } + program: { title: 'Show A' }, + }, }, { id: 2, @@ -177,9 +177,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T13:00:00', channel: 'ch2', custom_properties: { - program: { title: 'Show A' } - } - } + program: { title: 'Show A' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -194,22 +194,22 @@ describe('DVRUtils', () => { start_time: '2024-01-01T10:00:00', end_time: '2024-01-01T13:00:00', channel: 'ch1', - custom_properties: { program: { id: 1 } } + custom_properties: { program: { id: 1 } }, }, { id: 2, start_time: '2024-01-01T11:30:00', end_time: '2024-01-01T13:00:00', channel: 'ch2', - custom_properties: { program: { id: 2 } } + custom_properties: { program: { id: 2 } }, }, { id: 3, start_time: '2024-01-01T11:00:00', end_time: '2024-01-01T13:00:00', channel: 'ch3', - custom_properties: { program: { id: 3 } } - } + custom_properties: { program: { id: 3 } }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -227,8 +227,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T15:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Show A' } - } + program: { tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 2, @@ -236,8 +236,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T16:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Show A' } - } + program: { tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 3, @@ -245,9 +245,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T17:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Show A' } - } - } + program: { tvg_id: 'show1', title: 'Show A' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -265,8 +265,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T15:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Show A' } - } + program: { tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 2, @@ -274,9 +274,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T16:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'show a' } - } - } + program: { tvg_id: 'show1', title: 'show a' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -293,8 +293,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T15:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Show A' } - } + program: { tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 2, @@ -302,9 +302,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T16:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show2', title: 'Show A' } - } - } + program: { tvg_id: 'show2', title: 'Show A' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -321,22 +321,28 @@ describe('DVRUtils', () => { start_time: '2024-01-01T16:00:00', end_time: '2024-01-01T17:00:00', channel: 'ch1', - custom_properties: { program: { id: 1, tvg_id: 'show1', title: 'Show A' } } + custom_properties: { + program: { id: 1, tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 2, start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch2', - custom_properties: { program: { id: 2, tvg_id: 'show2', title: 'Show B' } } + custom_properties: { + program: { id: 2, tvg_id: 'show2', title: 'Show B' }, + }, }, { id: 3, start_time: '2024-01-01T15:00:00', end_time: '2024-01-01T16:00:00', channel: 'ch3', - custom_properties: { program: { id: 3, tvg_id: 'show3', title: 'Show C' } } - } + custom_properties: { + program: { id: 3, tvg_id: 'show3', title: 'Show C' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -346,7 +352,6 @@ describe('DVRUtils', () => { expect(result.upcoming[2].id).toBe(1); }); - it('should sort completed by end_time descending', () => { const recordings = [ { @@ -354,22 +359,22 @@ describe('DVRUtils', () => { start_time: '2024-01-01T08:00:00', end_time: '2024-01-01T09:00:00', channel: 'ch1', - custom_properties: { status: 'completed' } + custom_properties: { status: 'completed' }, }, { id: 2, start_time: '2024-01-01T10:00:00', end_time: '2024-01-01T11:00:00', channel: 'ch2', - custom_properties: { status: 'completed' } + custom_properties: { status: 'completed' }, }, { id: 3, start_time: '2024-01-01T09:00:00', end_time: '2024-01-01T10:00:00', channel: 'ch3', - custom_properties: { status: 'completed' } - } + custom_properties: { status: 'completed' }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -386,8 +391,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, }; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -418,15 +423,15 @@ describe('DVRUtils', () => { start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch1', - custom_properties: {} + custom_properties: {}, }, { id: 1, start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -440,8 +445,8 @@ describe('DVRUtils', () => { id: 1, start_time: '2024-01-01T11:00:00', end_time: '2024-01-01T13:00:00', - channel: 'ch1' - } + channel: 'ch1', + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -456,8 +461,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -472,8 +477,8 @@ describe('DVRUtils', () => { start_time: '2024-01-01T14:00:00', end_time: '2024-01-01T15:00:00', channel: 'ch1', - custom_properties: {} - } + custom_properties: {}, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -489,8 +494,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T15:00:00', channel: 'ch1', custom_properties: { - program: { id: 100, tvg_id: 'show1', title: 'Show A' } - } + program: { id: 100, tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 2, @@ -498,8 +503,8 @@ describe('DVRUtils', () => { end_time: '2024-01-01T15:00:00', channel: 'ch2', custom_properties: { - program: { id: 100, tvg_id: 'show1', title: 'Show A' } - } + program: { id: 100, tvg_id: 'show1', title: 'Show A' }, + }, }, { id: 3, @@ -507,9 +512,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T16:00:00', channel: 'ch1', custom_properties: { - program: { id: 101, tvg_id: 'show1', title: 'Show A' } - } - } + program: { id: 101, tvg_id: 'show1', title: 'Show A' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); @@ -526,9 +531,9 @@ describe('DVRUtils', () => { end_time: '2024-01-01T15:00:00', channel: 'ch1', custom_properties: { - program: { tvg_id: 'show1', title: 'Show A' } - } - } + program: { tvg_id: 'show1', title: 'Show A' }, + }, + }, ]; const result = DVRUtils.categorizeRecordings(recordings, toUserTime, now); diff --git a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js index 3b79fe38..a7539eeb 100644 --- a/frontend/src/utils/pages/__tests__/PluginsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/PluginsUtils.test.js @@ -9,8 +9,8 @@ vi.mock('../../../api.js', () => ({ setPluginEnabled: vi.fn(), importPlugin: vi.fn(), reloadPlugins: vi.fn(), - deletePlugin: vi.fn() - } + deletePlugin: vi.fn(), + }, })); describe('PluginsUtils', () => { @@ -66,7 +66,9 @@ describe('PluginsUtils', () => { API.updatePluginSettings.mockRejectedValue(error); - await expect(PluginsUtils.updatePluginSettings(key, settings)).rejects.toThrow('API error'); + await expect( + PluginsUtils.updatePluginSettings(key, settings) + ).rejects.toThrow('API error'); }); }); @@ -109,7 +111,9 @@ describe('PluginsUtils', () => { API.runPluginAction.mockRejectedValue(error); - await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow('Action not found'); + await expect(PluginsUtils.runPluginAction(key, actionId)).rejects.toThrow( + 'Action not found' + ); }); }); @@ -187,13 +191,17 @@ describe('PluginsUtils', () => { API.setPluginEnabled.mockRejectedValue(error); - await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow('Plugin not found'); + await expect(PluginsUtils.setPluginEnabled(key, next)).rejects.toThrow( + 'Plugin not found' + ); }); }); describe('importPlugin', () => { it('should call API importPlugin with importFile', async () => { - const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const importFile = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); await PluginsUtils.importPlugin(importFile); @@ -202,7 +210,9 @@ describe('PluginsUtils', () => { }); it('should return API response', async () => { - const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const importFile = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); const mockResponse = { key: 'imported-plugin', success: true }; API.importPlugin.mockResolvedValue(mockResponse); @@ -230,12 +240,16 @@ describe('PluginsUtils', () => { }); it('should propagate API errors', async () => { - const importFile = new File(['content'], 'plugin.zip', { type: 'application/zip' }); + const importFile = new File(['content'], 'plugin.zip', { + type: 'application/zip', + }); const error = new Error('Invalid plugin format'); API.importPlugin.mockRejectedValue(error); - await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow('Invalid plugin format'); + await expect(PluginsUtils.importPlugin(importFile)).rejects.toThrow( + 'Invalid plugin format' + ); }); }); diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js index 17783f24..b9efef94 100644 --- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js @@ -7,8 +7,8 @@ vi.mock('../../../api.js', () => ({ checkSetting: vi.fn(), updateSetting: vi.fn(), createSetting: vi.fn(), - rehashStreams: vi.fn() - } + rehashStreams: vi.fn(), + }, })); describe('SettingsUtils', () => { @@ -36,7 +36,11 @@ describe('SettingsUtils', () => { describe('createSetting', () => { it('should call API createSetting with values', async () => { - const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; + const values = { + key: 'new-setting', + name: 'New Setting', + value: 'value', + }; await SettingsUtils.createSetting(values); expect(API.createSetting).toHaveBeenCalledWith(values); expect(API.createSetting).toHaveBeenCalledTimes(1); @@ -59,13 +63,13 @@ describe('SettingsUtils', () => { key: 'stream_settings', value: { default_user_agent: 5, - m3u_hash_key: 'channel_name' - } - } + m3u_hash_key: 'channel_name', + }, + }, }; const changedSettings = { default_user_agent: 7, - preferred_region: 'UK' + preferred_region: 'UK', }; API.updateSetting.mockResolvedValue({}); @@ -78,8 +82,8 @@ describe('SettingsUtils', () => { value: { default_user_agent: 7, m3u_hash_key: 'channel_name', - preferred_region: 'UK' - } + preferred_region: 'UK', + }, }); }); @@ -88,11 +92,11 @@ describe('SettingsUtils', () => { stream_settings: { id: 1, key: 'stream_settings', - value: {} - } + value: {}, + }, }; const changedSettings = { - m3u_hash_key: ['channel_name', 'channel_number'] + m3u_hash_key: ['channel_name', 'channel_number'], }; API.updateSetting.mockResolvedValue({}); @@ -103,8 +107,8 @@ describe('SettingsUtils', () => { id: 1, key: 'stream_settings', value: { - m3u_hash_key: 'channel_name,channel_number' - } + m3u_hash_key: 'channel_name,channel_number', + }, }); }); @@ -113,12 +117,12 @@ describe('SettingsUtils', () => { stream_settings: { id: 1, key: 'stream_settings', - value: {} - } + value: {}, + }, }; const changedSettings = { default_user_agent: '5', - default_stream_profile: '3' + default_stream_profile: '3', }; API.updateSetting.mockResolvedValue({}); @@ -130,8 +134,8 @@ describe('SettingsUtils', () => { key: 'stream_settings', value: { default_user_agent: 5, - default_stream_profile: 3 - } + default_stream_profile: 3, + }, }); }); @@ -140,17 +144,17 @@ describe('SettingsUtils', () => { dvr_settings: { id: 2, key: 'dvr_settings', - value: {} + value: {}, }, stream_settings: { id: 1, key: 'stream_settings', - value: {} - } + value: {}, + }, }; const changedSettings = { comskip_enabled: true, - auto_import_mapped_files: false + auto_import_mapped_files: false, }; API.updateSetting.mockResolvedValue({}); @@ -166,15 +170,15 @@ describe('SettingsUtils', () => { id: 5, key: 'proxy_settings', value: { - buffering_speed: 1.0 - } - } + buffering_speed: 1.0, + }, + }, }; const changedSettings = { proxy_settings: { buffering_speed: 2.5, - buffering_timeout: 15 - } + buffering_timeout: 15, + }, }; API.updateSetting.mockResolvedValue({}); @@ -186,8 +190,8 @@ describe('SettingsUtils', () => { key: 'proxy_settings', value: { buffering_speed: 2.5, - buffering_timeout: 15 - } + buffering_timeout: 15, + }, }); }); @@ -195,8 +199,8 @@ describe('SettingsUtils', () => { const settings = {}; const changedSettings = { proxy_settings: { - buffering_speed: 2.5 - } + buffering_speed: 2.5, + }, }; API.createSetting.mockResolvedValue({}); @@ -207,8 +211,8 @@ describe('SettingsUtils', () => { key: 'proxy_settings', name: 'Proxy Settings', value: { - buffering_speed: 2.5 - } + buffering_speed: 2.5, + }, }); }); @@ -217,11 +221,11 @@ describe('SettingsUtils', () => { network_access: { id: 6, key: 'network_access', - value: [] - } + value: [], + }, }; const changedSettings = { - network_access: ['192.168.1.0/24', '10.0.0.0/8'] + network_access: ['192.168.1.0/24', '10.0.0.0/8'], }; API.updateSetting.mockResolvedValue({}); @@ -231,7 +235,7 @@ describe('SettingsUtils', () => { expect(API.updateSetting).toHaveBeenCalledWith({ id: 6, key: 'network_access', - value: ['192.168.1.0/24', '10.0.0.0/8'] + value: ['192.168.1.0/24', '10.0.0.0/8'], }); }); }); @@ -239,7 +243,7 @@ describe('SettingsUtils', () => { describe('parseSettings', () => { it('should parse grouped settings correctly', () => { const mockSettings = { - 'stream_settings': { + stream_settings: { id: 1, key: 'stream_settings', value: { @@ -247,19 +251,19 @@ describe('SettingsUtils', () => { default_stream_profile: 3, m3u_hash_key: 'channel_name,channel_number', preferred_region: 'US', - auto_import_mapped_files: true - } + auto_import_mapped_files: true, + }, }, - 'dvr_settings': { + dvr_settings: { id: 2, key: 'dvr_settings', value: { tv_template: '/media/tv/{show}/{season}/', comskip_enabled: false, pre_offset_minutes: 2, - post_offset_minutes: 5 - } - } + post_offset_minutes: 5, + }, + }, }; const result = SettingsUtils.parseSettings(mockSettings); @@ -280,13 +284,13 @@ describe('SettingsUtils', () => { it('should handle empty m3u_hash_key', () => { const mockSettings = { - 'stream_settings': { + stream_settings: { id: 1, key: 'stream_settings', value: { - m3u_hash_key: '' - } - } + m3u_hash_key: '', + }, + }, }; const result = SettingsUtils.parseSettings(mockSettings); @@ -295,30 +299,30 @@ describe('SettingsUtils', () => { it('should handle proxy_settings', () => { const mockSettings = { - 'proxy_settings': { + proxy_settings: { id: 5, key: 'proxy_settings', value: { buffering_speed: 2.5, - buffering_timeout: 15 - } - } + buffering_timeout: 15, + }, + }, }; const result = SettingsUtils.parseSettings(mockSettings); expect(result.proxy_settings).toEqual({ buffering_speed: 2.5, - buffering_timeout: 15 + buffering_timeout: 15, }); }); it('should handle network_access', () => { const mockSettings = { - 'network_access': { + network_access: { id: 6, key: 'network_access', - value: ['192.168.1.0/24', '10.0.0.0/8'] - } + value: ['192.168.1.0/24', '10.0.0.0/8'], + }, }; const result = SettingsUtils.parseSettings(mockSettings); @@ -331,12 +335,12 @@ describe('SettingsUtils', () => { const values = { time_zone: 'America/New_York', max_system_events: 2000, - comskip_enabled: true + comskip_enabled: true, }; const settings = { time_zone: { value: 'UTC' }, max_system_events: { value: 1000 }, - comskip_enabled: { value: false } + comskip_enabled: { value: false }, }; const changes = SettingsUtils.getChangedSettings(values, settings); @@ -344,18 +348,18 @@ describe('SettingsUtils', () => { expect(changes).toEqual({ time_zone: 'America/New_York', max_system_events: 2000, - comskip_enabled: true + comskip_enabled: true, }); }); it('should not detect unchanged values', () => { const values = { time_zone: 'UTC', - max_system_events: 1000 + max_system_events: 1000, }; const settings = { time_zone: { value: 'UTC' }, - max_system_events: { value: 1000 } + max_system_events: { value: 1000 }, }; const changes = SettingsUtils.getChangedSettings(values, settings); @@ -364,10 +368,10 @@ describe('SettingsUtils', () => { it('should preserve type of numeric values', () => { const values = { - max_system_events: 2000 + max_system_events: 2000, }; const settings = { - max_system_events: { value: 1000 } + max_system_events: { value: 1000 }, }; const changes = SettingsUtils.getChangedSettings(values, settings); @@ -377,16 +381,16 @@ describe('SettingsUtils', () => { it('should detect changes in array values', () => { const values = { - m3u_hash_key: ['channel_name', 'channel_number'] + m3u_hash_key: ['channel_name', 'channel_number'], }; const settings = { - m3u_hash_key: { value: 'channel_name' } + m3u_hash_key: { value: 'channel_name' }, }; const changes = SettingsUtils.getChangedSettings(values, settings); // Arrays are converted to comma-separated strings internally expect(changes).toEqual({ - m3u_hash_key: 'channel_name,channel_number' + m3u_hash_key: 'channel_name,channel_number', }); }); @@ -394,12 +398,12 @@ describe('SettingsUtils', () => { const values = { time_zone: 'America/New_York', proxy_settings: { - buffering_speed: 2.5 + buffering_speed: 2.5, }, - network_access: ['192.168.1.0/24'] + network_access: ['192.168.1.0/24'], }; const settings = { - time_zone: { value: 'UTC' } + time_zone: { value: 'UTC' }, }; const changes = SettingsUtils.getChangedSettings(values, settings); @@ -456,8 +460,8 @@ describe('SettingsUtils', () => { epg_match_ignore_prefixes: [], epg_match_ignore_suffixes: [], epg_match_ignore_custom: [], - } - } + }, + }, }; const changedSettings = { epg_match_mode: 'advanced', @@ -476,7 +480,7 @@ describe('SettingsUtils', () => { epg_match_ignore_prefixes: ['HD:'], epg_match_ignore_suffixes: [], epg_match_ignore_custom: [], - } + }, }); }); @@ -497,7 +501,7 @@ describe('SettingsUtils', () => { value: { epg_match_mode: 'advanced', epg_match_ignore_prefixes: ['Sling:'], - } + }, }); }); @@ -511,8 +515,8 @@ describe('SettingsUtils', () => { epg_match_ignore_prefixes: ['HD:'], epg_match_ignore_suffixes: [' 4K'], epg_match_ignore_custom: ['Plus'], - } - } + }, + }, }; const changedSettings = { epg_match_mode: 'default', @@ -530,7 +534,7 @@ describe('SettingsUtils', () => { epg_match_ignore_prefixes: ['HD:'], epg_match_ignore_suffixes: [' 4K'], epg_match_ignore_custom: ['Plus'], - } + }, }); }); }); diff --git a/frontend/src/utils/pages/__tests__/StatsUtils.test.js b/frontend/src/utils/pages/__tests__/StatsUtils.test.js index ccd422b1..8ae2421f 100644 --- a/frontend/src/utils/pages/__tests__/StatsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/StatsUtils.test.js @@ -8,8 +8,8 @@ vi.mock('../../../api.js', () => ({ stopClient: vi.fn(), stopVODClient: vi.fn(), fetchActiveChannelStats: vi.fn(), - getVODStats: vi.fn() - } + getVODStats: vi.fn(), + }, })); describe('StatsUtils', () => { @@ -41,7 +41,9 @@ describe('StatsUtils', () => { API.stopChannel.mockRejectedValue(error); - await expect(StatsUtils.stopChannel(id)).rejects.toThrow('Failed to stop channel'); + await expect(StatsUtils.stopChannel(id)).rejects.toThrow( + 'Failed to stop channel' + ); }); }); @@ -72,7 +74,9 @@ describe('StatsUtils', () => { API.stopClient.mockRejectedValue(error); - await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow('Failed to stop client'); + await expect(StatsUtils.stopClient(channelId, clientId)).rejects.toThrow( + 'Failed to stop client' + ); }); }); @@ -100,7 +104,9 @@ describe('StatsUtils', () => { API.stopVODClient.mockRejectedValue(error); - await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow('Failed to stop VOD client'); + await expect(StatsUtils.stopVODClient(clientId)).rejects.toThrow( + 'Failed to stop VOD client' + ); }); }); @@ -122,7 +128,9 @@ describe('StatsUtils', () => { API.fetchActiveChannelStats.mockRejectedValue(error); - await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow('Failed to fetch stats'); + await expect(StatsUtils.fetchActiveChannelStats()).rejects.toThrow( + 'Failed to fetch stats' + ); }); }); @@ -144,26 +152,29 @@ describe('StatsUtils', () => { API.getVODStats.mockRejectedValue(error); - await expect(StatsUtils.getVODStats()).rejects.toThrow('Failed to fetch VOD stats'); + await expect(StatsUtils.getVODStats()).rejects.toThrow( + 'Failed to fetch VOD stats' + ); }); }); describe('getCombinedConnections', () => { it('should combine channel history and VOD connections', () => { const channelHistory = { - 'ch1': { channel_id: 'ch1', uptime: 100 } + ch1: { channel_id: 'ch1', uptime: 100 }, }; const vodConnections = [ { content_type: 'movie', content_uuid: 'uuid1', - connections: [ - { client_id: 'client1', connected_at: 50 } - ] - } + connections: [{ client_id: 'client1', connected_at: 50 }], + }, ]; - const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections); + const result = StatsUtils.getCombinedConnections( + channelHistory, + vodConnections + ); expect(result).toHaveLength(2); expect(result[0].type).toBe('stream'); @@ -172,19 +183,20 @@ describe('StatsUtils', () => { it('should sort by sortKey descending (newest first)', () => { const channelHistory = { - 'ch1': { channel_id: 'ch1', uptime: 50 } + ch1: { channel_id: 'ch1', uptime: 50 }, }; const vodConnections = [ { content_type: 'movie', content_uuid: 'uuid1', - connections: [ - { client_id: 'client1', connected_at: 100 } - ] - } + connections: [{ client_id: 'client1', connected_at: 100 }], + }, ]; - const result = StatsUtils.getCombinedConnections(channelHistory, vodConnections); + const result = StatsUtils.getCombinedConnections( + channelHistory, + vodConnections + ); expect(result[0].sortKey).toBe(100); expect(result[1].sortKey).toBe(50); @@ -197,9 +209,9 @@ describe('StatsUtils', () => { content_uuid: 'uuid1', connections: [ { client_id: 'client1', connected_at: 100 }, - { client_id: 'client2', connected_at: 200 } - ] - } + { client_id: 'client2', connected_at: 200 }, + ], + }, ]; const result = StatsUtils.getCombinedConnections({}, vodConnections); @@ -218,9 +230,9 @@ describe('StatsUtils', () => { content_uuid: 'uuid1', connections: [ { client_id: 'client1', connected_at: 100 }, - { client_id: 'client2', connected_at: 200 } - ] - } + { client_id: 'client2', connected_at: 200 }, + ], + }, ]; const result = StatsUtils.getCombinedConnections({}, vodConnections); @@ -231,7 +243,7 @@ describe('StatsUtils', () => { it('should use uptime for stream sortKey', () => { const channelHistory = { - 'ch1': { channel_id: 'ch1', uptime: 150 } + ch1: { channel_id: 'ch1', uptime: 150 }, }; const result = StatsUtils.getCombinedConnections(channelHistory, []); @@ -241,7 +253,7 @@ describe('StatsUtils', () => { it('should default to 0 for missing uptime', () => { const channelHistory = { - 'ch1': { channel_id: 'ch1' } + ch1: { channel_id: 'ch1' }, }; const result = StatsUtils.getCombinedConnections(channelHistory, []); @@ -254,10 +266,8 @@ describe('StatsUtils', () => { { content_type: 'movie', content_uuid: 'uuid1', - connections: [ - { client_id: 'client1', connected_at: 250 } - ] - } + connections: [{ client_id: 'client1', connected_at: 250 }], + }, ]; const result = StatsUtils.getCombinedConnections({}, vodConnections); @@ -270,8 +280,8 @@ describe('StatsUtils', () => { { content_type: 'movie', content_uuid: 'uuid1', - connections: [] - } + connections: [], + }, ]; const result = StatsUtils.getCombinedConnections({}, vodConnections); @@ -290,8 +300,8 @@ describe('StatsUtils', () => { { content_type: 'movie', content_uuid: 'uuid1', - connections: null - } + connections: null, + }, ]; const result = StatsUtils.getCombinedConnections({}, vodConnections); @@ -303,13 +313,10 @@ describe('StatsUtils', () => { describe('getClientStats', () => { it('should extract clients from channel stats', () => { const stats = { - 'ch1': { + ch1: { channel_id: 'ch1', - clients: [ - { client_id: 'client1' }, - { client_id: 'client2' } - ] - } + clients: [{ client_id: 'client1' }, { client_id: 'client2' }], + }, }; const result = StatsUtils.getClientStats(stats); @@ -321,13 +328,11 @@ describe('StatsUtils', () => { it('should attach channel reference to each client', () => { const stats = { - 'ch1': { + ch1: { channel_id: 'ch1', name: 'Channel 1', - clients: [ - { client_id: 'client1' } - ] - } + clients: [{ client_id: 'client1' }], + }, }; const result = StatsUtils.getClientStats(stats); @@ -335,14 +340,14 @@ describe('StatsUtils', () => { expect(result[0].channel).toEqual({ channel_id: 'ch1', name: 'Channel 1', - clients: [{ client_id: 'client1' }] + clients: [{ client_id: 'client1' }], }); }); it('should handle channels without clients array', () => { const stats = { - 'ch1': { channel_id: 'ch1' }, - 'ch2': { channel_id: 'ch2', clients: null } + ch1: { channel_id: 'ch1' }, + ch2: { channel_id: 'ch2', clients: null }, }; const result = StatsUtils.getClientStats(stats); @@ -352,10 +357,10 @@ describe('StatsUtils', () => { it('should handle empty clients array', () => { const stats = { - 'ch1': { + ch1: { channel_id: 'ch1', - clients: [] - } + clients: [], + }, }; const result = StatsUtils.getClientStats(stats); @@ -365,14 +370,14 @@ describe('StatsUtils', () => { it('should combine clients from multiple channels', () => { const stats = { - 'ch1': { + ch1: { channel_id: 'ch1', - clients: [{ client_id: 'client1' }] + clients: [{ client_id: 'client1' }], }, - 'ch2': { + ch2: { channel_id: 'ch2', - clients: [{ client_id: 'client2' }] - } + clients: [{ client_id: 'client2' }], + }, }; const result = StatsUtils.getClientStats(stats); @@ -392,9 +397,7 @@ describe('StatsUtils', () => { describe('getStatsByChannelId', () => { it('should create stats indexed by channel_id', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', total_bytes: 1000 } - ] + channels: [{ channel_id: 'ch1', total_bytes: 1000 }], }; const prevChannelHistory = {}; const channelsByUUID = {}; @@ -415,15 +418,13 @@ describe('StatsUtils', () => { it('should calculate bitrates from previous history', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', total_bytes: 2000 } - ] + channels: [{ channel_id: 'ch1', total_bytes: 2000 }], }; const prevChannelHistory = { - 'ch1': { + ch1: { total_bytes: 1000, - bitrates: [500] - } + bitrates: [500], + }, }; const result = StatsUtils.getStatsByChannelId( @@ -440,15 +441,13 @@ describe('StatsUtils', () => { it('should limit bitrates array to 15 entries', () => { const prevBitrates = new Array(15).fill(100); const channelStats = { - channels: [ - { channel_id: 'ch1', total_bytes: 2000 } - ] + channels: [{ channel_id: 'ch1', total_bytes: 2000 }], }; const prevChannelHistory = { - 'ch1': { + ch1: { total_bytes: 1000, - bitrates: prevBitrates - } + bitrates: prevBitrates, + }, }; const result = StatsUtils.getStatsByChannelId( @@ -466,15 +465,13 @@ describe('StatsUtils', () => { it('should skip negative bitrates', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', total_bytes: 500 } - ] + channels: [{ channel_id: 'ch1', total_bytes: 500 }], }; const prevChannelHistory = { - 'ch1': { + ch1: { total_bytes: 1000, - bitrates: [] - } + bitrates: [], + }, }; const result = StatsUtils.getStatsByChannelId( @@ -490,18 +487,16 @@ describe('StatsUtils', () => { it('should merge channel data from channelsByUUID', () => { const channelStats = { - channels: [ - { channel_id: 'uuid1', total_bytes: 1000 } - ] + channels: [{ channel_id: 'uuid1', total_bytes: 1000 }], }; const channelsByUUID = { - 'uuid1': 'channel-key-1' + uuid1: 'channel-key-1', }; const channels = { 'channel-key-1': { name: 'Channel 1', - logo: 'logo.png' - } + logo: 'logo.png', + }, }; const result = StatsUtils.getStatsByChannelId( @@ -518,13 +513,11 @@ describe('StatsUtils', () => { it('should find and attach stream profile', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', stream_profile: '1' } - ] + channels: [{ channel_id: 'ch1', stream_profile: '1' }], }; const streamProfiles = [ { id: 1, name: 'HD Profile' }, - { id: 2, name: 'SD Profile' } + { id: 2, name: 'SD Profile' }, ]; const result = StatsUtils.getStatsByChannelId( @@ -540,13 +533,9 @@ describe('StatsUtils', () => { it('should default to Unknown for missing stream profile', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', stream_profile: '999' } - ] + channels: [{ channel_id: 'ch1', stream_profile: '999' }], }; - const streamProfiles = [ - { id: 1, name: 'HD Profile' } - ]; + const streamProfiles = [{ id: 1, name: 'HD Profile' }]; const result = StatsUtils.getStatsByChannelId( channelStats, @@ -561,9 +550,7 @@ describe('StatsUtils', () => { it('should preserve stream_id from channel stats', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', stream_id: 'stream-123' } - ] + channels: [{ channel_id: 'ch1', stream_id: 'stream-123' }], }; const result = StatsUtils.getStatsByChannelId( @@ -579,9 +566,7 @@ describe('StatsUtils', () => { it('should set stream_id to null if missing', () => { const channelStats = { - channels: [ - { channel_id: 'ch1' } - ] + channels: [{ channel_id: 'ch1' }], }; const result = StatsUtils.getStatsByChannelId( @@ -600,8 +585,8 @@ describe('StatsUtils', () => { const channelStats = { channels: [ { total_bytes: 1000 }, - { channel_id: 'ch1', total_bytes: 2000 } - ] + { channel_id: 'ch1', total_bytes: 2000 }, + ], }; const result = StatsUtils.getStatsByChannelId( @@ -614,7 +599,10 @@ describe('StatsUtils', () => { expect(result).not.toHaveProperty('undefined'); expect(result).toHaveProperty('ch1'); - expect(consoleSpy).toHaveBeenCalledWith('Found channel without channel_id:', { total_bytes: 1000 }); + expect(consoleSpy).toHaveBeenCalledWith( + 'Found channel without channel_id:', + { total_bytes: 1000 } + ); consoleSpy.mockRestore(); }); @@ -635,9 +623,7 @@ describe('StatsUtils', () => { it('should initialize empty bitrates array for new channels', () => { const channelStats = { - channels: [ - { channel_id: 'ch1', total_bytes: 1000 } - ] + channels: [{ channel_id: 'ch1', total_bytes: 1000 }], }; const result = StatsUtils.getStatsByChannelId( diff --git a/frontend/src/utils/pages/__tests__/VODsUtils.test.js b/frontend/src/utils/pages/__tests__/VODsUtils.test.js index e058ff0e..0905914c 100644 --- a/frontend/src/utils/pages/__tests__/VODsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/VODsUtils.test.js @@ -5,8 +5,8 @@ describe('VODsUtils', () => { describe('getCategoryOptions', () => { it('should return all categories option plus formatted categories', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' }, - 'cat2': { name: 'Drama', category_type: 'series' } + cat1: { name: 'Action', category_type: 'movie' }, + cat2: { name: 'Drama', category_type: 'series' }, }; const filters = { type: 'all' }; @@ -14,15 +14,21 @@ describe('VODsUtils', () => { expect(result).toHaveLength(3); expect(result[0]).toEqual({ value: '', label: 'All Categories' }); - expect(result[1]).toEqual({ value: 'Action|movie', label: 'Action (movie)' }); - expect(result[2]).toEqual({ value: 'Drama|series', label: 'Drama (series)' }); + expect(result[1]).toEqual({ + value: 'Action|movie', + label: 'Action (movie)', + }); + expect(result[2]).toEqual({ + value: 'Drama|series', + label: 'Drama (series)', + }); }); it('should filter to only movies when type is movies', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' }, - 'cat2': { name: 'Drama', category_type: 'series' }, - 'cat3': { name: 'Comedy', category_type: 'movie' } + cat1: { name: 'Action', category_type: 'movie' }, + cat2: { name: 'Drama', category_type: 'series' }, + cat3: { name: 'Comedy', category_type: 'movie' }, }; const filters = { type: 'movies' }; @@ -36,9 +42,9 @@ describe('VODsUtils', () => { it('should filter to only series when type is series', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' }, - 'cat2': { name: 'Drama', category_type: 'series' }, - 'cat3': { name: 'Sitcom', category_type: 'series' } + cat1: { name: 'Action', category_type: 'movie' }, + cat2: { name: 'Drama', category_type: 'series' }, + cat3: { name: 'Sitcom', category_type: 'series' }, }; const filters = { type: 'series' }; @@ -52,8 +58,8 @@ describe('VODsUtils', () => { it('should show all categories when type is all', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' }, - 'cat2': { name: 'Drama', category_type: 'series' } + cat1: { name: 'Action', category_type: 'movie' }, + cat2: { name: 'Drama', category_type: 'series' }, }; const filters = { type: 'all' }; @@ -74,7 +80,7 @@ describe('VODsUtils', () => { it('should create value with name and category_type separated by pipe', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' } + cat1: { name: 'Action', category_type: 'movie' }, }; const filters = { type: 'all' }; @@ -85,8 +91,8 @@ describe('VODsUtils', () => { it('should handle undefined type filter', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' }, - 'cat2': { name: 'Drama', category_type: 'series' } + cat1: { name: 'Action', category_type: 'movie' }, + cat2: { name: 'Drama', category_type: 'series' }, }; const filters = {}; @@ -97,9 +103,9 @@ describe('VODsUtils', () => { it('should filter out categories that do not match type', () => { const categories = { - 'cat1': { name: 'Action', category_type: 'movie' }, - 'cat2': { name: 'Drama', category_type: 'series' }, - 'cat3': { name: 'Comedy', category_type: 'movie' } + cat1: { name: 'Action', category_type: 'movie' }, + cat2: { name: 'Drama', category_type: 'series' }, + cat3: { name: 'Comedy', category_type: 'movie' }, }; const filters = { type: 'series' }; @@ -113,18 +119,14 @@ describe('VODsUtils', () => { describe('filterCategoriesToEnabled', () => { it('should return only categories with enabled m3u_accounts', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', - m3u_accounts: [ - { id: 1, enabled: true } - ] + m3u_accounts: [{ id: 1, enabled: true }], }, - 'cat2': { + cat2: { name: 'Drama', - m3u_accounts: [ - { id: 2, enabled: false } - ] - } + m3u_accounts: [{ id: 2, enabled: false }], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -135,14 +137,14 @@ describe('VODsUtils', () => { it('should include category if any m3u_account is enabled', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', m3u_accounts: [ { id: 1, enabled: false }, { id: 2, enabled: true }, - { id: 3, enabled: false } - ] - } + { id: 3, enabled: false }, + ], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -152,13 +154,13 @@ describe('VODsUtils', () => { it('should exclude category if all m3u_accounts are disabled', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', m3u_accounts: [ { id: 1, enabled: false }, - { id: 2, enabled: false } - ] - } + { id: 2, enabled: false }, + ], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -168,10 +170,10 @@ describe('VODsUtils', () => { it('should exclude category with empty m3u_accounts array', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', - m3u_accounts: [] - } + m3u_accounts: [], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -181,13 +183,11 @@ describe('VODsUtils', () => { it('should preserve original category data', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', category_type: 'movie', - m3u_accounts: [ - { id: 1, enabled: true } - ] - } + m3u_accounts: [{ id: 1, enabled: true }], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -203,18 +203,18 @@ describe('VODsUtils', () => { it('should filter multiple categories correctly', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', - m3u_accounts: [{ id: 1, enabled: true }] + m3u_accounts: [{ id: 1, enabled: true }], }, - 'cat2': { + cat2: { name: 'Drama', - m3u_accounts: [{ id: 2, enabled: false }] + m3u_accounts: [{ id: 2, enabled: false }], }, - 'cat3': { + cat3: { name: 'Comedy', - m3u_accounts: [{ id: 3, enabled: true }] - } + m3u_accounts: [{ id: 3, enabled: true }], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -227,10 +227,10 @@ describe('VODsUtils', () => { it('should handle category with null m3u_accounts', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', - m3u_accounts: null - } + m3u_accounts: null, + }, }; expect(() => { @@ -240,13 +240,13 @@ describe('VODsUtils', () => { it('should handle truthy enabled values', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', m3u_accounts: [ { id: 1, enabled: 1 }, - { id: 2, enabled: false } - ] - } + { id: 2, enabled: false }, + ], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); @@ -256,12 +256,10 @@ describe('VODsUtils', () => { it('should only match strict true for enabled', () => { const allCategories = { - 'cat1': { + cat1: { name: 'Action', - m3u_accounts: [ - { id: 1, enabled: 'true' } - ] - } + m3u_accounts: [{ id: 1, enabled: 'true' }], + }, }; const result = VODsUtils.filterCategoriesToEnabled(allCategories); diff --git a/scripts/wait_for_redis.py b/scripts/wait_for_redis.py index 38737728..0d278150 100644 --- a/scripts/wait_for_redis.py +++ b/scripts/wait_for_redis.py @@ -12,7 +12,7 @@ import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_interval=2): +def wait_for_redis(host='localhost', port=6379, db=0, password='', username='', max_retries=30, retry_interval=2): """Wait for Redis to become available""" redis_client = None retry_count = 0 @@ -25,6 +25,8 @@ def wait_for_redis(host='localhost', port=6379, db=0, max_retries=30, retry_inte host=host, port=port, db=db, + password=password if password else None, + username=username if username else None, socket_timeout=2, socket_connect_timeout=2 ) @@ -50,12 +52,14 @@ if __name__ == "__main__": host = os.environ.get('REDIS_HOST', 'localhost') port = int(os.environ.get('REDIS_PORT', 6379)) db = int(os.environ.get('REDIS_DB', 0)) + password = os.environ.get('REDIS_PASSWORD', '') + username = os.environ.get('REDIS_USER', '') max_retries = int(os.environ.get('REDIS_WAIT_RETRIES', 30)) retry_interval = int(os.environ.get('REDIS_WAIT_INTERVAL', 2)) logger.info(f"Starting Redis availability check at {host}:{port}/{db}") - if wait_for_redis(host, port, db, max_retries, retry_interval): + if wait_for_redis(host, port, db, password, username, max_retries, retry_interval): sys.exit(0) else: sys.exit(1) diff --git a/version.py b/version.py index f821e438..90c841a6 100644 --- a/version.py +++ b/version.py @@ -1,5 +1,5 @@ """ Dispatcharr version information. """ -__version__ = '0.18.1' # Follow semantic versioning (MAJOR.MINOR.PATCH) +__version__ = '0.19.0' # Follow semantic versioning (MAJOR.MINOR.PATCH) __timestamp__ = None # Set during CI/CD build process