diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml new file mode 100644 index 00000000..676b37df --- /dev/null +++ b/.github/workflows/issue-template-check.yml @@ -0,0 +1,31 @@ +on: + issues: + types: [opened, reopened] + +jobs: + check: + runs-on: ubuntu-latest + steps: + + # Request a bot user token + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + + # Do the actual check + - uses: Dispatcharr/repo-bot/actions/template-enforcer@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + event-type: issue + required-type: "Bug, Feature" + enforcement: close-and-lock + bypass-for-members: true + close-comment: | + ## Issue not opened from a template + + + This issue was closed because it was not opened using one of the available issue templates. + + Please [open a new issue]({new-issue-url}) and select the appropriate template. This helps us triage and address issues efficiently. diff --git a/.github/workflows/pr-compliance-check.yml b/.github/workflows/pr-compliance-check.yml new file mode 100644 index 00000000..135bd9c3 --- /dev/null +++ b/.github/workflows/pr-compliance-check.yml @@ -0,0 +1,65 @@ +on: + pull_request_target: + types: [opened, reopened, edited] + +jobs: + check: + runs-on: ubuntu-latest + steps: + + # Request a bot user token + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + + # Delete old bot comments before posting fresh ones + - uses: Dispatcharr/repo-bot/actions/comment-collapse@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + mode: delete + + # Template + Agreement Check + - uses: Dispatcharr/repo-bot/actions/template-enforcer@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + event-type: pull_request + required-markers: "## How was it tested?, ## Checklist, - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)" + enforcement: comment-only + bypass-for-members: true + close-comment: | + ## PR requirements not met + + + Your PR description is missing one or more required sections. Please ensure all of the following are present and filled out exactly as they appear in the [PR template](../blob/dev/.github/pull_request_template.md).: + + - **How was it tested?** Heading (describe how you verified your changes) + - **Checklist** Heading (completed from the [pull request template](../blob/dev/.github/pull_request_template.md)) + - **Contributor License Agreement** Checklist Item (the following item must appear checked in your description): + + > - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement) + + + Edit your PR description to add any missing items. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) for full contribution guidelines. Pull requests that do not follow the template, or that are wholly AI-generated or CLI-created, will be closed. + + # Target Check + - uses: Dispatcharr/repo-bot/actions/branch-guard@v1 + with: + github-token: ${{ steps.app-token.outputs.token }} + allowed-targets: "dev" + enforcement: comment-only + bypass-for-members: true + comment: | + ## Wrong target branch + + + This PR targets `{target-branch}`, but all contributions must target the `dev` branch. + + > **To fix this:** + > 1. Open the PR and click **Edit** next to the title + > 2. Change the base branch from `{target-branch}` to `dev` + > 3. Save the change + + + Unsure about our contribution guidelines? See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e998abd..406e7b59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv) + +### Changed + +- **Docker base image now uses a multi-stage build.** The builder stage installs compilers and dev headers (`gcc`, `g++`, `gfortran`, `build-essential`, `libopenblas-dev`, `libpcre3-dev`, `python3.13-dev`, `ninja-build`) to create the virtual environment and compile the legacy NumPy wheel (cpu-baseline=none for old hardware). The final runtime image starts from the same ffmpeg base, copies only the prebuilt venv and wheel from the builder, and installs only the runtime libraries needed at runtime - eliminating compiler binaries from production containers and reducing final image size. — Thanks [@kensac](https://github.com/kensac) +- **`libpq-dev` removed from both build and runtime stages.** The project uses `psycopg2-binary`, which bundles its own statically linked copy of libpq inside the wheel. No system libpq headers or shared library are needed at build or runtime. + +### Performance + +- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012) + +### Fixed + +- **Plugins with available updates were not sorting to the top of the Plugin Browse list.** The sort weight function previously treated `update_available` plugins the same as any other installed plugin, leaving them buried. They now receive the highest sort priority (below the search/filter results header) so users can spot pending updates immediately. — Thanks [@sethwv](https://github.com/sethwv) +- **Disabled state on the size-labeled install button rendered with a warm tint instead of appearing clearly disabled.** The CSS filter was `brightness(0.65) saturate(0.7)`, which left a faint color cast. It is now `grayscale(1) brightness(0.55)`, matching the standard disabled appearance. — Thanks [@sethwv](https://github.com/sethwv) +- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. + ## [0.25.1] - 2026-05-23 ### Fixed diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py index f531fef8..a169b7db 100644 --- a/apps/backups/tasks.py +++ b/apps/backups/tasks.py @@ -1,6 +1,7 @@ import logging import traceback from celery import shared_task +from django.core.management import call_command from . import services @@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str): backup_file = backup_dir / filename logger.info(f"[RESTORE] Backup file path: {backup_file}") services.restore_backup(backup_file) + logger.info(f"[RESTORE] Running migrations after restore...") + call_command('migrate', '--noinput', verbosity=1) logger.info(f"[RESTORE] Task {self.request.id} completed successfully") return { "status": "completed", diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 3f75693e..9bd1d284 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -419,7 +419,8 @@ class ChannelStatus: info['stream_name'] = stream_name # Add data throughput information to basic info - total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) + # TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip + total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES) if total_bytes_bytes: total_bytes = int(total_bytes_bytes) info['total_bytes'] = total_bytes @@ -460,29 +461,31 @@ class ChannelStatus: client_key = RedisKeys.client_metadata(channel_id, client_id) + # Fetch only the fields we need in one round-trip (hmget returns a list + # in the same order as the requested keys; values are None if absent) + ua, ip, connected_at, user_id, output_format, raw_profile_id = ( + proxy_server.redis_client.hmget( + client_key, + 'user_agent', 'ip_address', 'connected_at', 'user_id', + 'output_format', 'output_profile_id', + ) + ) + client_info = { 'client_id': client_id, + 'user_agent': ua, + 'output_format': output_format or 'mpegts', } - user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') - client_info['user_agent'] = user_agent_bytes + if ip: + client_info['ip_address'] = ip - ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') - if ip_address_bytes: - client_info['ip_address'] = ip_address_bytes + if connected_at: + client_info['connected_at'] = float(connected_at) - connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') - if connected_at_bytes: - client_info['connected_at'] = float(connected_at_bytes) + if user_id: + client_info['user_id'] = user_id - user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') - if user_id_bytes: - client_info['user_id'] = user_id_bytes - - output_format = proxy_server.redis_client.hget(client_key, 'output_format') - client_info['output_format'] = output_format or 'mpegts' - - raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id') if raw_profile_id and raw_profile_id not in ('None', '0', ''): client_info['output_profile_id'] = int(raw_profile_id) else: diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index a2610c93..d882bfca 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -468,14 +468,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): if proxy_server.redis_client: metadata_key = RedisKeys.channel_metadata(channel_id) - url_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.URL - ) - ua_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.USER_AGENT - ) - profile_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.STREAM_PROFILE + url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget( + metadata_key, + ChannelMetadataField.URL, + ChannelMetadataField.USER_AGENT, + ChannelMetadataField.STREAM_PROFILE, ) if url_bytes: diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index db2339ed..69ee15e4 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -98,8 +98,7 @@ def get_user_active_connections(user_id): channel_id = parts[2] client_id = parts[4] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'connected_at') + client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at') logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] channel_id = {channel_id}") @@ -124,9 +123,9 @@ def get_user_active_connections(user_id): if len(parts) >= 2: client_id = parts[1] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'created_at') - content_uuid = redis_client.hget(key, 'content_uuid') + client_user_id, connected_at, content_uuid = redis_client.hmget( + key, 'user_id', 'created_at', 'content_uuid' + ) logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] client_id = {client_id}") diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 3040e189..bc2e9b4d 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,4 +1,10 @@ -FROM lscr.io/linuxserver/ffmpeg:latest +# ============================================================================== +# Stage 1: builder +# Installs the Python toolchain + compilers, creates the virtual environment and +# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end +# up in the final image — only the artifacts below are copied forward. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS builder ENV DEBIAN_FRONTEND=noninteractive ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy @@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get update \ && apt-get install --no-install-recommends -y \ python3.13 python3.13-dev python3.13-venv libpython3.13 \ - libpcre3 libpcre3-dev libpq-dev procps pciutils \ - nginx comskip \ - vlc-bin vlc-plugin-base \ - build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build + libpcre3 libpcre3-dev \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \ + && rm -rf /var/lib/apt/lists/* # --- Install UV --- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ @@ -28,11 +33,12 @@ WORKDIR /tmp/build COPY pyproject.toml /tmp/build/ COPY version.py /tmp/build/ COPY README.md /tmp/build/ -RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ - rm -rf /tmp/build +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +# build/pip are installed into the venv only long enough to produce the wheel, +# then uninstalled so they are not carried into the final image's venv. RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ cd /tmp && \ $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ @@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build cd numpy-*/ && \ $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ - cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ + cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \ uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip -# --- Clean up build dependencies to reduce image size --- -RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ - apt-get autoremove -y --purge && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* +# ============================================================================== +# Stage 2: final runtime image +# Same ffmpeg base, but installs only the runtime system libraries (no compilers) +# and copies the prebuilt virtual environment + NumPy wheel from the builder. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS final + +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy +ENV VIRTUAL_ENV=/dispatcharrpy +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +# --- Install runtime system dependencies (no build toolchain) --- +# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI; +# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget +# and software-properties-common are kept for TLS and the AIO runtime apt step. +RUN apt-get update && apt-get install --no-install-recommends -y \ + ca-certificates software-properties-common gnupg2 curl wget \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install --no-install-recommends -y \ + python3.13 python3.13-venv libpython3.13 \ + libpcre3 libopenblas0 procps pciutils \ + nginx comskip \ + vlc-bin vlc-plugin-base \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Install UV (used at runtime to swap in the legacy NumPy wheel) --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder --- +COPY --from=builder /dispatcharrpy /dispatcharrpy +COPY --from=builder /opt/numpy-*.whl /opt/ # --- Set up Redis 7.x --- RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 02b7d15d..c58755b2 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -439,12 +439,12 @@ export default function BackupManager() { try { await API.restoreBackup(selectedBackup.name); notifications.show({ - title: 'Success', + title: 'Restore Complete', message: - 'Backup restored successfully. You may need to refresh the page.', + 'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.', color: 'green', }); - setTimeout(() => window.location.reload(), 2000); + setTimeout(() => window.location.reload(), 4000); } catch (error) { notifications.show({ title: 'Error', diff --git a/frontend/src/components/theme/SizedInstallButton.jsx b/frontend/src/components/theme/SizedInstallButton.jsx index 780aedb5..7bcb1819 100644 --- a/frontend/src/components/theme/SizedInstallButton.jsx +++ b/frontend/src/components/theme/SizedInstallButton.jsx @@ -71,7 +71,7 @@ const SizedInstallButton = ({ : 'var(--mantine-primary-color-filled-hover)' : colorVar, filter: isDisabled - ? 'brightness(0.65) saturate(0.7)' + ? 'grayscale(1) brightness(0.55)' : hovered ? 'brightness(0.86)' : 'brightness(0.82)', diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx index ccc81b28..dd4a3a6d 100644 --- a/frontend/src/pages/PluginBrowse.jsx +++ b/frontend/src/pages/PluginBrowse.jsx @@ -8,6 +8,7 @@ import { Group, Loader, Modal, + NativeSelect, NumberInput, Pagination, Select, @@ -68,6 +69,7 @@ export default function PluginBrowsePage() { const saveIntervalTimer = useRef(null); const recentlyInstalledSlugs = useRef(new Set()); + const recentlyUpdatedSlugs = useRef(new Set()); const recentlyUninstalledSlugs = useRef(new Set()); const [searchQuery, setSearchQuery] = useState(''); @@ -75,7 +77,14 @@ export default function PluginBrowsePage() { const [filterRepo, setFilterRepo] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [page, setPage] = useState(1); - const perPage = 9; + const [perPage, setPerPage] = useState(() => { + const stored = localStorage.getItem('pluginBrowsePerPage'); + return stored && !isNaN(Number(stored)) ? Number(stored) : 9; + }); + const handlePerPageChange = (value) => { + setPerPage(Number(value)); + localStorage.setItem('pluginBrowsePerPage', value); + }; const hasFetched = useRef(false); @@ -294,6 +303,8 @@ export default function PluginBrowsePage() { // Pre-sort weights: deprecated → installed → incompatible sink to bottom (in that order) // Recently installed plugins are exempt so they don't jump away after install const weight = (p) => { + if (p.install_status === 'update_available') return -1; + if (recentlyUpdatedSlugs.current.has(p.slug)) return -1; if (recentlyInstalledSlugs.current.has(p.slug)) return 0; if (recentlyUninstalledSlugs.current.has(p.slug)) return 2; const meetsMin = @@ -335,10 +346,10 @@ export default function PluginBrowsePage() { appVersion, ]); - // Reset to page 1 when filters/search change + // Reset to page 1 when filters/search/page-size change React.useEffect(() => { setPage(1); - }, [searchQuery, filterRepo, filterStatus, sortBy]); + }, [searchQuery, filterRepo, filterStatus, sortBy, perPage]); const totalPages = Math.ceil(filteredPlugins.length / perPage); const paginatedPlugins = filteredPlugins.slice( @@ -347,7 +358,15 @@ export default function PluginBrowsePage() { ); return ( - + + @@ -467,38 +486,69 @@ export default function PluginBrowsePage() { )} {!loading && filteredPlugins.length > 0 && ( - <> - - {paginatedPlugins.map((p) => ( - 1} - onBeforeInstall={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - }} - onInstalled={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - fetchAvailablePlugins(); - }} - onUninstalled={(slug) => { - if (slug) recentlyUninstalledSlugs.current.add(slug); - }} - /> - ))} - - {totalPages > 1 && ( - - - - )} - + + {paginatedPlugins.map((p) => ( + 1} + onBeforeInstall={(slug) => { + if (slug) { + if (p.install_status === 'update_available') { + recentlyUpdatedSlugs.current.add(slug); + } else { + recentlyInstalledSlugs.current.add(slug); + } + } + }} + onInstalled={(slug) => { + if (slug) recentlyInstalledSlugs.current.add(slug); + fetchAvailablePlugins(); + }} + onUninstalled={(slug) => { + if (slug) recentlyUninstalledSlugs.current.add(slug); + }} + /> + ))} + + )} + + + + {!loading && filteredPlugins.length > 0 && ( + + + Page Size + handlePerPageChange(e.target.value)} + styles={{ input: { textAlignLast: 'center' } }} + /> + + + {`${(page - 1) * perPage + 1} to ${Math.min(page * perPage, filteredPlugins.length)} of ${filteredPlugins.length}`} + + + )} {/* Manage Repos Modal */} diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ec5f7042..86c7f1c3 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -212,19 +212,19 @@ const StatsPage = () => { } }, [setVodStats]); + // Always fetch once on mount, regardless of polling interval setting + useEffect(() => { + fetchChannelStats(); + fetchVODStats(); + }, [fetchChannelStats, fetchVODStats]); + // Set up polling for stats when on stats page useEffect(() => { - const location = window.location; - const isOnStatsPage = location.pathname === '/stats'; + const isOnStatsPage = window.location.pathname === '/stats'; if (isOnStatsPage && refreshInterval > 0) { setIsPollingActive(true); - // Initial fetch - fetchChannelStats(); - fetchVODStats(); - - // Set up interval const interval = setInterval(() => { fetchChannelStats(); fetchVODStats(); @@ -239,12 +239,6 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Fetch initial stats on component mount (for immediate data when navigating to page) - useEffect(() => { - fetchChannelStats(); - fetchVODStats(); - }, [fetchChannelStats, fetchVODStats]); - useEffect(() => { console.log('Processing channel stats:', channelStats); if ( diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index d809a47a..92cf8560 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -220,8 +220,8 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); }); }); @@ -283,33 +283,37 @@ describe('StatsPage', () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); // Advance timers by 5 seconds await act(async () => { vi.advanceTimersByTime(5000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); - it('does not poll when interval is 0', async () => { + it('does not poll when interval is 0 but still fetches once on mount', async () => { vi.useFakeTimers(); useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); + // Should still fetch once on mount even with interval = 0 expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(10000); }); + // Should not have polled — count stays at 1 expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -319,7 +323,7 @@ describe('StatsPage', () => { const { unmount } = render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); unmount(); @@ -328,7 +332,7 @@ describe('StatsPage', () => { }); // Should not fetch again after unmount - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -338,14 +342,14 @@ describe('StatsPage', () => { it('refreshes stats when Refresh Now button is clicked', async () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); const refreshButton = screen.getByText('Refresh Now'); fireEvent.click(refreshButton); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); @@ -406,14 +410,14 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(1); }); const stopButton = await screen.findByTestId('stop-vod-client-client-1'); fireEvent.click(stopButton); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); });