merged in dev
BIN
1.png
|
Before Width: | Height: | Size: 1.4 MiB |
BIN
2.png
|
Before Width: | Height: | Size: 298 KiB |
BIN
3.png
|
Before Width: | Height: | Size: 2.3 MiB |
BIN
4.png
|
Before Width: | Height: | Size: 750 KiB |
BIN
5.png
|
Before Width: | Height: | Size: 394 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
120
README.md
|
|
@ -1,61 +1,87 @@
|
|||
# 🎬 Dispatcharr — Your Ultimate IPTV & Stream Management Companion
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Dispatcharr/Dispatcharr/refs/heads/main/frontend/src/images/logo.png" height="200" alt="Dispatcharr Logo" />
|
||||
<img src="https://github.com/Dispatcharr/Dispatcharr/blob/main/frontend/src/images/logo.png?raw=true" height="200" alt="Dispatcharr Logo" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 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
|
||||
|
||||

|
||||
<div align="center">
|
||||
<img src="docs/images/channels.png" alt="Channels" width="750"/>
|
||||
<img src="docs/images/guide.png" alt="TV Guide" width="750"/>
|
||||
<img src="docs/images/stats.png" alt="Stats & Monitoring" width="750"/>
|
||||
<img src="docs/images/m3u-epg-manager.png" alt="M3U & EPG Manager" width="750"/>
|
||||
<img src="docs/images/vod-library.png" alt="VOD Library" width="750"/>
|
||||
<img src="docs/images/settings.png" alt="Settings" width="750"/>
|
||||
</div>
|
||||
|
||||

|
||||
---
|
||||
|
||||

|
||||
## 🛠️ Troubleshooting & Help
|
||||
|
||||

|
||||
- **General help?** Visit [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/)
|
||||
- **Community support?** Join our [Discord](https://discord.gg/Sp45V5BcxU)
|
||||
|
||||

|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
# 🚀 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_
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
BIN
docs/images/channels.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/images/guide.png
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
docs/images/m3u-epg-manager.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/images/settings.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/images/stats.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/images/vod-library.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
|
@ -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 [
|
|||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,10 +4,24 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="manifest" href="/static/site.webmanifest">
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/static/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/static/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/static/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/static/site.webmanifest" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dispatcharr</title>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,4 @@ class ErrorBoundary extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
export default ErrorBoundary;
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<Box
|
||||
key={`placeholder-${channel.id}-${placeholderIndex}`}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
pos='absolute'
|
||||
left={placeholderIndex * (HOUR_WIDTH * 2)}
|
||||
top={0}
|
||||
w={HOUR_WIDTH * 2}
|
||||
h={rowHeight - 4}
|
||||
bd={'1px dashed #2D3748'}
|
||||
bdrs={4}
|
||||
display={'flex'}
|
||||
c='#4A5568'
|
||||
>
|
||||
<Text size="sm">No program data</Text>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: Math.ceil(24 / 2) }).map(
|
||||
(_, placeholderIndex) => (
|
||||
<Box
|
||||
key={`placeholder-${channel.id}-${placeholderIndex}`}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
pos="absolute"
|
||||
left={placeholderIndex * (HOUR_WIDTH * 2)}
|
||||
top={0}
|
||||
w={HOUR_WIDTH * 2}
|
||||
h={rowHeight - 4}
|
||||
bd={'1px dashed #2D3748'}
|
||||
bdrs={4}
|
||||
display={'flex'}
|
||||
c="#4A5568"
|
||||
>
|
||||
<Text size="sm">No program data</Text>
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -75,7 +77,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
|
|||
}}
|
||||
display={'flex'}
|
||||
h={'100%'}
|
||||
pos='relative'
|
||||
pos="relative"
|
||||
>
|
||||
<Box
|
||||
className="channel-logo"
|
||||
|
|
@ -96,7 +98,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
|
|||
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"
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
|
|
@ -167,7 +169,7 @@ const GuideRow = React.memo(({ index, style, data }) => {
|
|||
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)
|
||||
)
|
||||
) : <PlaceholderProgram />}
|
||||
) : (
|
||||
<PlaceholderProgram />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default GuideRow;
|
||||
export default GuideRow;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
key={format(time)}
|
||||
style={{
|
||||
borderRight: '1px solid #8DAFAA',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
|
||||
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
|
||||
}}
|
||||
w={HOUR_WIDTH}
|
||||
h={'40px'}
|
||||
pos='relative'
|
||||
c='#a0aec0'
|
||||
onClick={(e) => handleTimeClick(time, e)}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ transform: 'none' }}
|
||||
pos='absolute'
|
||||
top={8}
|
||||
left={4}
|
||||
bdrs={2}
|
||||
lh={1.2}
|
||||
ta='left'
|
||||
return (
|
||||
<Box
|
||||
key={format(time)}
|
||||
style={{
|
||||
borderRight: '1px solid #8DAFAA',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isNewDay ? '2px solid #3BA882' : 'none',
|
||||
backgroundColor: isNewDay ? '#1E2A27' : '#1B2421',
|
||||
}}
|
||||
w={HOUR_WIDTH}
|
||||
h={'40px'}
|
||||
pos="relative"
|
||||
c="#a0aec0"
|
||||
onClick={(e) => handleTimeClick(time, e)}
|
||||
>
|
||||
<Text
|
||||
span
|
||||
size="xs"
|
||||
display={'block'}
|
||||
opacity={0.7}
|
||||
fw={isNewDay ? 600 : 400}
|
||||
c={isNewDay ? '#3BA882' : undefined}
|
||||
size="sm"
|
||||
style={{ transform: 'none' }}
|
||||
pos="absolute"
|
||||
top={8}
|
||||
left={4}
|
||||
bdrs={2}
|
||||
lh={1.2}
|
||||
ta="left"
|
||||
>
|
||||
{formatDayLabel(time)}
|
||||
<Text
|
||||
span
|
||||
size="xs"
|
||||
display={'block'}
|
||||
opacity={0.7}
|
||||
fw={isNewDay ? 600 : 400}
|
||||
c={isNewDay ? '#3BA882' : undefined}
|
||||
>
|
||||
{formatDayLabel(time)}
|
||||
</Text>
|
||||
{format(time, timeFormat)}
|
||||
<Text span size="xs" ml={1} opacity={0.7} />
|
||||
</Text>
|
||||
{format(time, timeFormat)}
|
||||
<Text span size="xs" ml={1} opacity={0.7} />
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
zIndex: 10,
|
||||
}}
|
||||
pos='absolute'
|
||||
left={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
w={'1px'}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: '#27272A',
|
||||
zIndex: 10,
|
||||
}}
|
||||
pos="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
bottom={0}
|
||||
w={'1px'}
|
||||
/>
|
||||
|
||||
<Box
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
pos='absolute'
|
||||
bottom={0}
|
||||
w={'100%'}
|
||||
display={'flex'}
|
||||
p={'0 1px'}
|
||||
>
|
||||
{[15, 30, 45].map((minute) => (
|
||||
<Box
|
||||
key={minute}
|
||||
style={{ backgroundColor: '#718096' }}
|
||||
w={'1px'}
|
||||
h={'8px'}
|
||||
pos='absolute'
|
||||
bottom={0}
|
||||
left={`${(minute / 60) * 100}%`}
|
||||
<Box
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
w={'100%'}
|
||||
display={'flex'}
|
||||
p={'0 1px'}
|
||||
>
|
||||
{[15, 30, 45].map((minute) => (
|
||||
<Box
|
||||
key={minute}
|
||||
style={{ backgroundColor: '#718096' }}
|
||||
w={'1px'}
|
||||
h={'8px'}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={`${(minute / 60) * 100}%`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const HourTimeline = React.memo(
|
||||
({ hourTimeline, timeFormat, formatDayLabel, handleTimeClick }) => {
|
||||
return (
|
||||
<>
|
||||
{hourTimeline.map((hourData) => (
|
||||
<HourBlock
|
||||
key={format(hourData.time)}
|
||||
hourData={hourData}
|
||||
timeFormat={timeFormat}
|
||||
formatDayLabel={formatDayLabel}
|
||||
handleTimeClick={handleTimeClick}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
const HourTimeline = React.memo(({
|
||||
hourTimeline,
|
||||
timeFormat,
|
||||
formatDayLabel,
|
||||
handleTimeClick
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{hourTimeline.map((hourData) => (
|
||||
<HourBlock
|
||||
key={format(hourData.time)}
|
||||
hourData={hourData}
|
||||
timeFormat={timeFormat}
|
||||
formatDayLabel={formatDayLabel}
|
||||
handleTimeClick={handleTimeClick}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default HourTimeline;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
export default RecordingSynopsis;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,6 @@ const PluginActionList = ({
|
|||
runningActionId,
|
||||
handlePluginRun,
|
||||
}) => {
|
||||
console.log(plugin);
|
||||
return plugin.actions.map((action) => (
|
||||
<Group key={action.id} justify="space-between">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
export default SeriesCard;
|
||||
|
|
|
|||
|
|
@ -140,4 +140,4 @@ const VODCard = ({ vod, onClick }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default VODCard;
|
||||
export default VODCard;
|
||||
|
|
|
|||
|
|
@ -74,23 +74,33 @@ export default function ProgramRecordingModal({
|
|||
Just this one
|
||||
</Button>
|
||||
|
||||
<Button variant="light" onClick={() => {
|
||||
onRecordSeriesAll();
|
||||
onClose();
|
||||
}}>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
onRecordSeriesAll();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Every episode
|
||||
</Button>
|
||||
|
||||
<Button variant="light" onClick={() => {
|
||||
onRecordSeriesNew();
|
||||
onClose();
|
||||
}}>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
onRecordSeriesNew();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
New episodes only
|
||||
</Button>
|
||||
|
||||
{recording && (
|
||||
<>
|
||||
<Button color="orange" variant="light" onClick={handleRemoveRecording}>
|
||||
<Button
|
||||
color="orange"
|
||||
variant="light"
|
||||
onClick={handleRemoveRecording}
|
||||
>
|
||||
Remove this recording
|
||||
</Button>
|
||||
<Button color="red" variant="light" onClick={handleRemoveSeries}>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@ import React from 'react';
|
|||
import { Modal, Stack, Text, Flex, Group, Button } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import { deleteSeriesAndRule } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import { evaluateSeriesRulesByTvgId, fetchRules } from '../../pages/guideUtils.js';
|
||||
import {
|
||||
evaluateSeriesRulesByTvgId,
|
||||
fetchRules,
|
||||
} from '../../pages/guideUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
export default function SeriesRecordingModal({
|
||||
opened,
|
||||
onClose,
|
||||
rules,
|
||||
onRulesUpdate
|
||||
opened,
|
||||
onClose,
|
||||
rules,
|
||||
onRulesUpdate,
|
||||
}) {
|
||||
const handleEvaluateNow = async (r) => {
|
||||
await evaluateSeriesRulesByTvgId(r.tvg_id);
|
||||
|
|
@ -56,35 +59,36 @@ export default function SeriesRecordingModal({
|
|||
No series rules configured
|
||||
</Text>
|
||||
)}
|
||||
{rules && rules.map((r) => (
|
||||
<Flex
|
||||
key={`${r.tvg_id}-${r.mode}`}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text size="sm">
|
||||
{r.title || r.tvg_id} —{' '}
|
||||
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => handleEvaluateNow(r)}
|
||||
>
|
||||
Evaluate Now
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={() => handleRemoveSeries(r)}
|
||||
>
|
||||
Remove this series (scheduled)
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
))}
|
||||
{rules &&
|
||||
rules.map((r) => (
|
||||
<Flex
|
||||
key={`${r.tvg_id}-${r.mode}`}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text size="sm">
|
||||
{r.title || r.tvg_id} —{' '}
|
||||
{r.mode === 'new' ? 'New episodes' : 'Every episode'}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => handleEvaluateNow(r)}
|
||||
>
|
||||
Evaluate Now
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={() => handleRemoveSeries(r)}
|
||||
>
|
||||
Remove this series (scheduled)
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Modal
|
||||
|
|
@ -191,8 +192,8 @@ const EPGMatchModal = ({
|
|||
/>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(<EPGMatchModal {...defaultProps} />);
|
||||
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(<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />);
|
||||
render(
|
||||
<EPGMatchModal {...defaultProps} selectedChannelIds={selectedIds} />
|
||||
);
|
||||
|
||||
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(<EPGMatchModal {...defaultProps} />);
|
||||
|
|
@ -270,13 +296,23 @@ describe('EPGMatchModal', () => {
|
|||
|
||||
describe('UI Text', () => {
|
||||
it('should show correct text for selected channels', () => {
|
||||
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />);
|
||||
expect(screen.getByText(/Match channels to EPG data for 3 selected channel\(s\)/)).toBeInTheDocument();
|
||||
render(
|
||||
<EPGMatchModal {...defaultProps} selectedChannelIds={[1, 2, 3]} />
|
||||
);
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Match channels to EPG data for 3 selected channel\(s\)/
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show correct text for all channels', () => {
|
||||
render(<EPGMatchModal {...defaultProps} selectedChannelIds={[]} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -180,7 +180,6 @@ const ChannelTableHeader = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const assignChannels = async () => {
|
||||
try {
|
||||
// Call our custom API endpoint
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ import App from './App';
|
|||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const M3UPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent/>
|
||||
<PageContent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default M3UPage;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box p={10}>
|
||||
<Button
|
||||
|
|
@ -170,11 +172,13 @@ const DVRPage = () => {
|
|||
{ maxWidth: '36rem', cols: 1 },
|
||||
]}
|
||||
>
|
||||
{<RecordingList
|
||||
list={inProgress}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>}
|
||||
{
|
||||
<RecordingList
|
||||
list={inProgress}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>
|
||||
}
|
||||
{inProgress.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing recording right now.
|
||||
|
|
@ -196,11 +200,13 @@ const DVRPage = () => {
|
|||
{ maxWidth: '36rem', cols: 1 },
|
||||
]}
|
||||
>
|
||||
{<RecordingList
|
||||
list={upcoming}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>}
|
||||
{
|
||||
<RecordingList
|
||||
list={upcoming}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>
|
||||
}
|
||||
{upcoming.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No upcoming recordings.
|
||||
|
|
@ -222,11 +228,13 @@ const DVRPage = () => {
|
|||
{ maxWidth: '36rem', cols: 1 },
|
||||
]}
|
||||
>
|
||||
{<RecordingList
|
||||
list={completed}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>}
|
||||
{
|
||||
<RecordingList
|
||||
list={completed}
|
||||
onOpenDetails={openDetails}
|
||||
onOpenRecurring={openRuleModal}
|
||||
/>
|
||||
}
|
||||
{completed.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No completed recordings yet.
|
||||
|
|
@ -286,4 +294,4 @@ const DVRPage = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default DVRPage;
|
||||
export default DVRPage;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@ import VODLogosTable from '../components/tables/VODLogosTable';
|
|||
import { showNotification } from '../utils/notificationUtils.js';
|
||||
|
||||
const LogosPage = () => {
|
||||
const logos = useLogosStore(s => s.logos);
|
||||
const totalCount = useVODLogosStore(s => s.totalCount);
|
||||
const logos = useLogosStore((s) => s.logos);
|
||||
const totalCount = useVODLogosStore((s) => s.totalCount);
|
||||
const [activeTab, setActiveTab] = useState('channel');
|
||||
const logoCount = activeTab === 'channel'
|
||||
? Object.keys(logos).length
|
||||
: totalCount;
|
||||
const logoCount =
|
||||
activeTab === 'channel' ? Object.keys(logos).length : totalCount;
|
||||
|
||||
const loadChannelLogos = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -38,11 +37,7 @@ const LogosPage = () => {
|
|||
return (
|
||||
<Box>
|
||||
{/* Header with title and tabs */}
|
||||
<Box
|
||||
style={{ justifyContent: 'center' }}
|
||||
display={'flex'}
|
||||
p={'10px 0'}
|
||||
>
|
||||
<Box style={{ justifyContent: 'center' }} display={'flex'} p={'10px 0'}>
|
||||
<Flex
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
|
|
@ -58,7 +53,7 @@ const LogosPage = () => {
|
|||
fz={'20px'}
|
||||
fw={500}
|
||||
lh={1}
|
||||
c='white'
|
||||
c="white"
|
||||
mb={0}
|
||||
lts={'-0.3px'}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import {
|
|||
Text,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { showNotification, updateNotification, } from '../utils/notificationUtils.js';
|
||||
import {
|
||||
showNotification,
|
||||
updateNotification,
|
||||
} from '../utils/notificationUtils.js';
|
||||
import { usePluginStore } from '../store/plugins.jsx';
|
||||
import {
|
||||
deletePluginByKey,
|
||||
|
|
@ -34,8 +37,9 @@ import {
|
|||
} from '../utils/pages/PluginsUtils.js';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import ErrorBoundary from '../components/ErrorBoundary.jsx';
|
||||
const PluginCard = React.lazy(() =>
|
||||
import('../components/cards/PluginCard.jsx'));
|
||||
const PluginCard = React.lazy(
|
||||
() => import('../components/cards/PluginCard.jsx')
|
||||
);
|
||||
|
||||
const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
|
||||
const plugins = usePluginStore((state) => state.plugins);
|
||||
|
|
@ -68,7 +72,7 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{plugins.length > 0 &&
|
||||
{plugins.length > 0 && (
|
||||
<SimpleGrid
|
||||
cols={2}
|
||||
spacing="md"
|
||||
|
|
@ -91,13 +95,13 @@ const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => {
|
|||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</SimpleGrid>
|
||||
}
|
||||
)}
|
||||
|
||||
{plugins.length === 0 && (
|
||||
<Box>
|
||||
<Text c="dimmed">
|
||||
No plugins found. Drop a plugin into <code>/data/plugins</code>{' '}
|
||||
and reload.
|
||||
No plugins found. Drop a plugin into <code>/data/plugins</code> and
|
||||
reload.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -255,12 +259,15 @@ export default function PluginsPage() {
|
|||
};
|
||||
};
|
||||
|
||||
const handleConfirm = useCallback((confirmed) => {
|
||||
const resolver = confirmConfig.resolve;
|
||||
setConfirmOpen(false);
|
||||
setConfirmConfig({ title: '', message: '', resolve: null });
|
||||
if (resolver) resolver(confirmed);
|
||||
}, [confirmConfig.resolve]);
|
||||
const handleConfirm = useCallback(
|
||||
(confirmed) => {
|
||||
const resolver = confirmConfig.resolve;
|
||||
setConfirmOpen(false);
|
||||
setConfirmConfig({ title: '', message: '', resolve: null });
|
||||
if (resolver) resolver(confirmed);
|
||||
},
|
||||
[confirmConfig.resolve]
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShellMain p={16}>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ const PageContent = () => {
|
|||
<UsersTable />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const UsersPage = () => {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PageContent/>
|
||||
<PageContent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import ChannelsPage from '../Channels';
|
|||
vi.mock('../../store/auth');
|
||||
vi.mock('../../hooks/useLocalStorage');
|
||||
vi.mock('../../components/tables/ChannelsTable', () => ({
|
||||
default: () => <div data-testid="channels-table">ChannelsTable</div>
|
||||
default: () => <div data-testid="channels-table">ChannelsTable</div>,
|
||||
}));
|
||||
vi.mock('../../components/tables/StreamsTable', () => ({
|
||||
default: () => <div data-testid="streams-table">StreamsTable</div>
|
||||
default: () => <div data-testid="streams-table">StreamsTable</div>,
|
||||
}));
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import useUserAgentsStore from '../../store/userAgents';
|
|||
|
||||
vi.mock('../../store/userAgents');
|
||||
vi.mock('../../components/tables/M3UsTable', () => ({
|
||||
default: () => <div data-testid="m3us-table">M3UsTable</div>
|
||||
default: () => <div data-testid="m3us-table">M3UsTable</div>,
|
||||
}));
|
||||
vi.mock('../../components/tables/EPGsTable', () => ({
|
||||
default: () => <div data-testid="epgs-table">EPGsTable</div>
|
||||
default: () => <div data-testid="epgs-table">EPGsTable</div>,
|
||||
}));
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||
|
|
@ -30,4 +30,4 @@ describe('ContentSourcesPage', () => {
|
|||
expect(screen.getByTestId('m3us-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('epgs-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import useAuthStore from '../../store/auth';
|
|||
|
||||
vi.mock('../../store/auth');
|
||||
vi.mock('../../components/forms/LoginForm', () => ({
|
||||
default: () => <div data-testid="login-form">LoginForm</div>
|
||||
default: () => <div data-testid="login-form">LoginForm</div>,
|
||||
}));
|
||||
vi.mock('../../components/forms/SuperuserForm', () => ({
|
||||
default: () => <div data-testid="superuser-form">SuperuserForm</div>
|
||||
default: () => <div data-testid="superuser-form">SuperuserForm</div>,
|
||||
}));
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Text: ({ children }) => <div>{children}</div>,
|
||||
|
|
@ -18,7 +18,7 @@ describe('Login', () => {
|
|||
it('renders SuperuserForm when superuser does not exist', async () => {
|
||||
useAuthStore.mockReturnValue(false);
|
||||
|
||||
render(<Login/>);
|
||||
render(<Login />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('superuser-form')).toBeInTheDocument();
|
||||
|
|
@ -29,7 +29,7 @@ describe('Login', () => {
|
|||
it('renders LoginForm when superuser exists', () => {
|
||||
useAuthStore.mockReturnValue(true);
|
||||
|
||||
render(<Login/>);
|
||||
render(<Login />);
|
||||
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|||
import LogosPage from '../Logos';
|
||||
import useLogosStore from '../../store/logos';
|
||||
import useVODLogosStore from '../../store/vodLogos';
|
||||
import { showNotification, updateNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
showNotification,
|
||||
updateNotification,
|
||||
} from '../../utils/notificationUtils.js';
|
||||
|
||||
vi.mock('../../store/logos');
|
||||
vi.mock('../../store/vodLogos');
|
||||
|
|
@ -12,16 +15,21 @@ vi.mock('../../utils/notificationUtils.js', () => ({
|
|||
updateNotification: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../components/tables/LogosTable', () => ({
|
||||
default: () => <div data-testid="logos-table">LogosTable</div>
|
||||
default: () => <div data-testid="logos-table">LogosTable</div>,
|
||||
}));
|
||||
vi.mock('../../components/tables/VODLogosTable', () => ({
|
||||
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>
|
||||
default: () => <div data-testid="vod-logos-table">VODLogosTable</div>,
|
||||
}));
|
||||
vi.mock('@mantine/core', () => {
|
||||
const tabsComponent = ({ children, value, onChange }) =>
|
||||
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>{children}</div>;
|
||||
const tabsComponent = ({ children, value, onChange }) => (
|
||||
<div data-testid="tabs" data-value={value} onClick={() => onChange('vod')}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
tabsComponent.List = ({ children }) => <div>{children}</div>;
|
||||
tabsComponent.Tab = ({ children, value }) => <button data-value={value}>{children}</button>;
|
||||
tabsComponent.Tab = ({ children, value }) => (
|
||||
<button data-value={value}>{children}</button>
|
||||
);
|
||||
|
||||
return {
|
||||
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</span>
|
||||
),
|
||||
Button: ({ children, onClick, leftSection, variant, color, loading, disabled, fullWidth }) => (
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
leftSection,
|
||||
variant,
|
||||
color,
|
||||
loading,
|
||||
disabled,
|
||||
fullWidth,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
|
|
@ -73,13 +85,16 @@ vi.mock('@mantine/core', async () => {
|
|||
),
|
||||
Divider: ({ my }) => <hr data-my={my} />,
|
||||
ActionIcon: ({ children, onClick, color, variant, title }) => (
|
||||
<button onClick={onClick} data-color={color} data-variant={variant} title={title}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-color={color}
|
||||
data-variant={variant}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SimpleGrid: ({ children, cols }) => (
|
||||
<div data-cols={cols}>{children}</div>
|
||||
),
|
||||
SimpleGrid: ({ children, cols }) => <div data-cols={cols}>{children}</div>,
|
||||
Modal: ({ opened, onClose, title, children, size, centered }) =>
|
||||
opened ? (
|
||||
<div data-testid="modal" data-size={size} data-centered={centered}>
|
||||
|
|
@ -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(<PluginsPage />);
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import useAuthStore from '../../store/auth';
|
|||
|
||||
vi.mock('../../store/auth');
|
||||
vi.mock('../../components/tables/UsersTable', () => ({
|
||||
default: () => <div data-testid="users-table">UsersTable</div>
|
||||
default: () => <div data-testid="users-table">UsersTable</div>,
|
||||
}));
|
||||
vi.mock('@mantine/core', () => ({
|
||||
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ vi.mock('../../components/SeriesModal', () => ({
|
|||
<div data-testid="series-name">{series?.name}</div>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
) : null
|
||||
) : null,
|
||||
}));
|
||||
vi.mock('../../components/VODModal', () => ({
|
||||
default: ({ opened, vod, onClose }) =>
|
||||
|
|
@ -25,26 +25,30 @@ vi.mock('../../components/VODModal', () => ({
|
|||
<div data-testid="vod-name">{vod?.name}</div>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
) : null
|
||||
) : null,
|
||||
}));
|
||||
vi.mock('../../components/cards/VODCard', () => ({
|
||||
default: ({ vod, onClick }) => (
|
||||
<div data-testid="vod-card" onClick={() => onClick(vod)}>
|
||||
<div>{vod.name}</div>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
vi.mock('../../components/cards/SeriesCard', () => ({
|
||||
default: ({ series, onClick }) => (
|
||||
<div data-testid="series-card" onClick={() => onClick(series)}>
|
||||
<div>{series.name}</div>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@mantine/core', () => {
|
||||
const gridComponent = ({ children, ...props }) => <div {...props}>{children}</div>;
|
||||
gridComponent.Col = ({ children, ...props }) => <div {...props}>{children}</div>;
|
||||
const gridComponent = ({ children, ...props }) => (
|
||||
<div {...props}>{children}</div>
|
||||
);
|
||||
gridComponent.Col = ({ children, ...props }) => (
|
||||
<div {...props}>{children}</div>
|
||||
);
|
||||
|
||||
return {
|
||||
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||
|
|
@ -97,7 +101,9 @@ vi.mock('@mantine/core', () => {
|
|||
<button onClick={() => onChange(page - 1)} disabled={page === 1}>
|
||||
Prev
|
||||
</button>
|
||||
<span>{page} of {total}</span>
|
||||
<span>
|
||||
{page} of {total}
|
||||
</span>
|
||||
<button onClick={() => onChange(page + 1)} disabled={page === total}>
|
||||
Next
|
||||
</button>
|
||||
|
|
@ -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(<VODsPage />);
|
||||
|
||||
|
|
@ -370,9 +368,7 @@ describe('VODsPage', () => {
|
|||
totalCount: 25,
|
||||
pageSize: 12,
|
||||
};
|
||||
useVODStore.mockImplementation((selector) =>
|
||||
selector(stateWithPagination)
|
||||
);
|
||||
useVODStore.mockImplementation((selector) => selector(stateWithPagination));
|
||||
|
||||
render(<VODsPage />);
|
||||
|
||||
|
|
@ -405,9 +401,7 @@ describe('VODsPage', () => {
|
|||
pageSize: 12,
|
||||
currentPage: 1,
|
||||
};
|
||||
useVODStore.mockImplementation((selector) =>
|
||||
selector(stateWithPagination)
|
||||
);
|
||||
useVODStore.mockImplementation((selector) => selector(stateWithPagination));
|
||||
|
||||
render(<VODsPage />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -38,4 +38,4 @@ export const usePluginStore = create((set, get) => ({
|
|||
set({ plugins: [] });
|
||||
get().fetchPlugins();
|
||||
},
|
||||
}));
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,4 +89,4 @@ export const getSeriesInfo = (customProps) => {
|
|||
const cp = customProps || {};
|
||||
const pr = cp.program || {};
|
||||
return { tvg_id: pr.tvg_id, title: pr.title };
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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".',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -63,4 +63,4 @@ export const deleteRecurringRuleById = async (ruleId) => {
|
|||
|
||||
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
|
||||
await API.updateRecurringRule(ruleId, { enabled: checked });
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||