From a9402207fb95c2ab6ab473b0b0e7b8a1f2defae1 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 24 Feb 2026 00:58:36 -0800
Subject: [PATCH 01/15] Added tests
---
.../__tests__/ConfirmationDialog.test.jsx | 275 ++++++
.../__tests__/ErrorBoundary.test.jsx | 99 ++
.../src/components/__tests__/Field.test.jsx | 388 ++++++++
.../__tests__/FloatingVideo.test.jsx | 454 +++++++++
.../components/__tests__/GuideRow.test.jsx | 335 +++++++
.../__tests__/HourTimeline.test.jsx | 318 +++++++
.../components/__tests__/LazyLogo.test.jsx | 398 ++++++++
.../__tests__/M3URefreshNotification.test.jsx | 712 +++++++++++++++
.../__tests__/RecordingSynopsis.test.jsx | 169 ++++
.../components/__tests__/SeriesModal.test.jsx | 864 ++++++++++++++++++
.../src/components/__tests__/Sidebar.test.jsx | 455 +++++++++
.../__tests__/SystemEvents.test.jsx | 251 +++++
.../components/__tests__/VODModal.test.jsx | 439 +++++++++
.../__tests__/YouTubeTrailerModal.test.jsx | 109 +++
.../__tests__/FloatingVideoUtils.test.js | 235 +++++
.../__tests__/SeriesModalUtils.test.js | 397 ++++++++
.../__tests__/VODModalUtils.test.js | 344 +++++++
17 files changed, 6242 insertions(+)
create mode 100644 frontend/src/components/__tests__/ConfirmationDialog.test.jsx
create mode 100644 frontend/src/components/__tests__/ErrorBoundary.test.jsx
create mode 100644 frontend/src/components/__tests__/Field.test.jsx
create mode 100644 frontend/src/components/__tests__/FloatingVideo.test.jsx
create mode 100644 frontend/src/components/__tests__/GuideRow.test.jsx
create mode 100644 frontend/src/components/__tests__/HourTimeline.test.jsx
create mode 100644 frontend/src/components/__tests__/LazyLogo.test.jsx
create mode 100644 frontend/src/components/__tests__/M3URefreshNotification.test.jsx
create mode 100644 frontend/src/components/__tests__/RecordingSynopsis.test.jsx
create mode 100644 frontend/src/components/__tests__/SeriesModal.test.jsx
create mode 100644 frontend/src/components/__tests__/Sidebar.test.jsx
create mode 100644 frontend/src/components/__tests__/SystemEvents.test.jsx
create mode 100644 frontend/src/components/__tests__/VODModal.test.jsx
create mode 100644 frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx
create mode 100644 frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js
create mode 100644 frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
create mode 100644 frontend/src/utils/components/__tests__/VODModalUtils.test.js
diff --git a/frontend/src/components/__tests__/ConfirmationDialog.test.jsx b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx
new file mode 100644
index 00000000..741f2f16
--- /dev/null
+++ b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx
@@ -0,0 +1,275 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import ConfirmationDialog from '../ConfirmationDialog';
+import useWarningsStore from '../../store/warnings';
+
+// Mock the warnings store
+vi.mock('../../store/warnings');
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+ ) : null,
+ Group: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+ ),
+ Box: ({ children }) => {children}
,
+ };
+});
+
+describe('ConfirmationDialog', () => {
+ const mockOnClose = vi.fn();
+ const mockOnConfirm = vi.fn();
+ const mockOnSuppressChange = vi.fn();
+ const mockSuppressWarning = vi.fn();
+ const mockIsWarningSuppressed = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useWarningsStore.mockImplementation((selector) => {
+ const state = {
+ suppressWarning: mockSuppressWarning,
+ isWarningSuppressed: mockIsWarningSuppressed,
+ };
+ return selector ? selector(state) : state;
+ });
+ });
+
+ it('should render when opened', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
+ });
+
+ it('should not render when closed', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should display custom title and message', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item');
+ expect(screen.getByText('This action cannot be undone')).toBeInTheDocument();
+ });
+
+ it('should call onConfirm when confirm button is clicked', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Delete'));
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onClose when cancel button is clicked', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should show suppress checkbox when actionKey is provided', () => {
+ render(
+
+ );
+
+ expect(screen.getByLabelText("Don't ask me again")).toBeInTheDocument();
+ });
+
+ it('should not show suppress checkbox when actionKey is not provided', () => {
+ render(
+
+ );
+
+ expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument();
+ });
+
+ it('should call suppressWarning when suppress is checked and confirmed', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText("Don't ask me again"));
+ fireEvent.click(screen.getByText('Confirm'));
+
+ expect(mockSuppressWarning).toHaveBeenCalledWith('delete-action');
+ });
+
+ it('should call onSuppressChange when suppress checkbox is toggled', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText("Don't ask me again"));
+ expect(mockOnSuppressChange).toHaveBeenCalledWith(true);
+ });
+
+ it('should show delete file option when enabled', () => {
+ render(
+
+ );
+
+ expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument();
+ });
+
+ it('should pass deleteFiles state to onConfirm when delete option is checked', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Also delete files from disk'));
+ fireEvent.click(screen.getByText('Confirm'));
+
+ expect(mockOnConfirm).toHaveBeenCalledWith(true);
+ });
+
+ it('should reset deleteFiles state after confirmation', () => {
+ const { rerender } = render(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Also delete files from disk'));
+ fireEvent.click(screen.getByText('Confirm'));
+
+ rerender(
+
+ );
+
+ expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked();
+ });
+
+ it('should show loading state on confirm button', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Confirm')).toBeDisabled();
+ });
+
+ it('should disable cancel button when loading', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Cancel')).toBeDisabled();
+ });
+
+ it('should initialize suppress checkbox based on store state', () => {
+ mockIsWarningSuppressed.mockReturnValue(true);
+
+ render(
+
+ );
+
+ expect(screen.getByLabelText("Don't ask me again")).toBeChecked();
+ });
+});
diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.jsx b/frontend/src/components/__tests__/ErrorBoundary.test.jsx
new file mode 100644
index 00000000..bab0778d
--- /dev/null
+++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx
@@ -0,0 +1,99 @@
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import ErrorBoundary from '../ErrorBoundary';
+
+// Component that throws an error for testing
+const ThrowError = ({ shouldThrow }) => {
+ if (shouldThrow) {
+ throw new Error('Test error');
+ }
+ return Child component
;
+};
+
+describe('ErrorBoundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Suppress console.error for cleaner test output
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ it('should render children when there is no error', () => {
+ render(
+
+ Test content
+
+ );
+
+ expect(screen.getByText('Test content')).toBeInTheDocument();
+ });
+
+ it('should render error message when child component throws error', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(screen.queryByText('Child component')).not.toBeInTheDocument();
+ });
+
+ it('should not render error message when child component does not throw', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Child component')).toBeInTheDocument();
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
+ });
+
+ it('should handle multiple children', () => {
+ render(
+
+ First child
+ Second child
+
+ );
+
+ expect(screen.getByText('First child')).toBeInTheDocument();
+ expect(screen.getByText('Second child')).toBeInTheDocument();
+ });
+
+ it('should catch errors from nested children', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('should have hasError state set to true after catching error', () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Verify error boundary rendered fallback UI
+ expect(container.querySelector('div')).toHaveTextContent('Something went wrong');
+ });
+
+ it('should have hasError state set to false initially', () => {
+ const { container } = render(
+
+ Normal content
+
+ );
+
+ // Verify children are rendered (not error state)
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/__tests__/Field.test.jsx b/frontend/src/components/__tests__/Field.test.jsx
new file mode 100644
index 00000000..3f57356d
--- /dev/null
+++ b/frontend/src/components/__tests__/Field.test.jsx
@@ -0,0 +1,388 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Field } from '../Field';
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ TextInput: ({ label, description, value, onChange }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ NumberInput: ({ label, description, value, onChange }) => (
+
+
+
onChange(Number(e.target.value))}
+ aria-describedby={description}
+ />
+ {description &&
{description}
}
+
+ ),
+ Switch: ({ label, description, checked, onChange }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ Select: ({ label, description, value, data, onChange }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ };
+});
+
+describe('Field', () => {
+ const mockOnChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('TextInput (string type)', () => {
+ it('should render TextInput for string type', () => {
+ const field = {
+ id: 'name',
+ type: 'string',
+ label: 'Name',
+ help_text: 'Enter your name',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Name')).toBeInTheDocument();
+ expect(screen.getByText('Enter your name')).toBeInTheDocument();
+ });
+
+ it('should use provided value', () => {
+ const field = {
+ id: 'name',
+ type: 'string',
+ label: 'Name',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Name')).toHaveValue('John');
+ });
+
+ it('should use default value when value is null', () => {
+ const field = {
+ id: 'name',
+ type: 'string',
+ label: 'Name',
+ default: 'Default Name',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Name')).toHaveValue('Default Name');
+ });
+
+ it('should call onChange with field id and value', () => {
+ const field = {
+ id: 'name',
+ type: 'string',
+ label: 'Name',
+ default: '',
+ };
+
+ render();
+
+ fireEvent.change(screen.getByLabelText('Name'), {
+ target: { value: 'New Value' },
+ });
+
+ expect(mockOnChange).toHaveBeenCalledWith('name', 'New Value');
+ });
+ });
+
+ describe('NumberInput (number type)', () => {
+ it('should render NumberInput for number type', () => {
+ const field = {
+ id: 'age',
+ type: 'number',
+ label: 'Age',
+ help_text: 'Enter your age',
+ default: 0,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Age')).toBeInTheDocument();
+ expect(screen.getByText('Enter your age')).toBeInTheDocument();
+ });
+
+ it('should use provided value', () => {
+ const field = {
+ id: 'age',
+ type: 'number',
+ label: 'Age',
+ default: 0,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Age')).toHaveValue(25);
+ });
+
+ it('should default to 0 when value and default are null', () => {
+ const field = {
+ id: 'age',
+ type: 'number',
+ label: 'Age',
+ default: null,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Age')).toHaveValue(0);
+ });
+
+ it('should call onChange with field id and numeric value', () => {
+ const field = {
+ id: 'age',
+ type: 'number',
+ label: 'Age',
+ default: 0,
+ };
+
+ render();
+
+ fireEvent.change(screen.getByLabelText('Age'), {
+ target: { value: '30' },
+ });
+
+ expect(mockOnChange).toHaveBeenCalledWith('age', 30);
+ });
+ });
+
+ describe('Switch (boolean type)', () => {
+ it('should render Switch for boolean type', () => {
+ const field = {
+ id: 'active',
+ type: 'boolean',
+ label: 'Active',
+ help_text: 'Toggle active state',
+ default: false,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Active')).toBeInTheDocument();
+ expect(screen.getByText('Toggle active state')).toBeInTheDocument();
+ });
+
+ it('should be checked when value is true', () => {
+ const field = {
+ id: 'active',
+ type: 'boolean',
+ label: 'Active',
+ default: false,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Active')).toBeChecked();
+ });
+
+ it('should be unchecked when value is false', () => {
+ const field = {
+ id: 'active',
+ type: 'boolean',
+ label: 'Active',
+ default: false,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Active')).not.toBeChecked();
+ });
+
+ it('should use default value when value is null', () => {
+ const field = {
+ id: 'active',
+ type: 'boolean',
+ label: 'Active',
+ default: true,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Active')).toBeChecked();
+ });
+
+ it('should call onChange with field id and checked state', () => {
+ const field = {
+ id: 'active',
+ type: 'boolean',
+ label: 'Active',
+ default: false,
+ };
+
+ render();
+
+ fireEvent.click(screen.getByLabelText('Active'));
+
+ expect(mockOnChange).toHaveBeenCalledWith('active', true);
+ });
+ });
+
+ describe('Select (select type)', () => {
+ it('should render Select for select type', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ help_text: 'Select your country',
+ default: '',
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'ca', label: 'Canada' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Country')).toBeInTheDocument();
+ expect(screen.getByText('Select your country')).toBeInTheDocument();
+ });
+
+ it('should render options correctly', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ default: '',
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'ca', label: 'Canada' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByText('United States')).toBeInTheDocument();
+ expect(screen.getByText('Canada')).toBeInTheDocument();
+ });
+
+ it('should use provided value', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ default: '',
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'ca', label: 'Canada' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Country')).toHaveValue('ca');
+ });
+
+ it('should convert value to string', () => {
+ const field = {
+ id: 'status',
+ type: 'select',
+ label: 'Status',
+ default: 1,
+ options: [
+ { value: 1, label: 'Active' },
+ { value: 2, label: 'Inactive' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Status')).toHaveValue('1');
+ });
+
+ it('should handle empty options array', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ default: '',
+ options: null,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Country')).toBeInTheDocument();
+ });
+
+ it('should call onChange with field id and selected value', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ default: '',
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'ca', label: 'Canada' },
+ ],
+ };
+
+ render();
+
+ fireEvent.change(screen.getByLabelText('Country'), {
+ target: { value: 'ca' },
+ });
+
+ expect(mockOnChange).toHaveBeenCalledWith('country', 'ca');
+ });
+ });
+
+ describe('Default fallback', () => {
+ it('should render TextInput for unknown type', () => {
+ const field = {
+ id: 'custom',
+ type: 'unknown',
+ label: 'Custom Field',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Custom Field')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/FloatingVideo.test.jsx b/frontend/src/components/__tests__/FloatingVideo.test.jsx
new file mode 100644
index 00000000..9c537d2f
--- /dev/null
+++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx
@@ -0,0 +1,454 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import FloatingVideo from '../FloatingVideo';
+import useVideoStore from '../../store/useVideoStore';
+
+// Mock the video store
+vi.mock('../../store/useVideoStore');
+
+// Mock mpegts.js
+vi.mock('mpegts.js', () => ({
+ default: {
+ createPlayer: vi.fn(),
+ getFeatureList: vi.fn(),
+ Events: {
+ LOADING_COMPLETE: 'loading_complete',
+ METADATA_ARRIVED: 'metadata_arrived',
+ ERROR: 'error',
+ MEDIA_INFO: 'media_info',
+ },
+ },
+}));
+
+// Import the mocked module after mocking
+const mpegts = (await import('mpegts.js')).default;
+
+// Mock react-draggable
+vi.mock('react-draggable', () => ({
+ default: ({ children, nodeRef }) => {children}
,
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ CloseButton: ({ onClick, onTouchEnd }) => (
+
+ ),
+ Flex: ({ children, ...props }) => {children}
,
+ Box: ({ children, ...props }) => {children}
,
+ Loader: () => Loading...
,
+ Text: ({ children, ...props }) => {children}
,
+ };
+});
+
+describe('FloatingVideo', () => {
+ const mockHideVideo = vi.fn();
+ let mockPlayer;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ // Mock HTMLVideoElement methods
+ HTMLVideoElement.prototype.load = vi.fn();
+ HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
+ HTMLVideoElement.prototype.pause = vi.fn();
+
+ mockPlayer = {
+ attachMediaElement: vi.fn(),
+ load: vi.fn(),
+ play: vi.fn(() => Promise.resolve()),
+ pause: vi.fn(),
+ destroy: vi.fn(),
+ on: vi.fn(),
+ };
+
+ mpegts.createPlayer.mockReturnValue(mockPlayer);
+ mpegts.getFeatureList.mockReturnValue({ mseLivePlayback: true });
+
+ useVideoStore.mockImplementation((selector) => {
+ const state = {
+ isVisible: false,
+ streamUrl: null,
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ });
+ });
+
+ describe('Visibility', () => {
+ it('should not render when isVisible is false', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should not render when streamUrl is null', () => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: null,
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render when isVisible is true and streamUrl is provided', () => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+
+ render();
+ expect(screen.getByTestId('close-button')).toBeInTheDocument();
+ });
+ });
+
+ describe('Live Stream Player', () => {
+ beforeEach(() => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+ });
+
+ it('should initialize mpegts player for live streams', () => {
+ render();
+
+ expect(mpegts.createPlayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'mpegts',
+ url: 'http://example.com/stream.ts',
+ isLive: true,
+ })
+ );
+ });
+
+ it('should show loading state initially', () => {
+ render();
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ expect(screen.getByText('Loading stream...')).toBeInTheDocument();
+ });
+
+ it('should attach player to video element', () => {
+ render();
+ expect(mockPlayer.attachMediaElement).toHaveBeenCalled();
+ });
+
+ it('should handle player errors', async () => {
+ render();
+
+ const errorCallback = mockPlayer.on.mock.calls.find(
+ (call) => call[0] === mpegts.Events.ERROR
+ )?.[1];
+
+ errorCallback('MediaError', 'AC3 codec not supported');
+
+ await screen.findByText(/Audio codec not supported/i);
+ });
+
+ it('should handle unsupported browser', () => {
+ mpegts.getFeatureList.mockReturnValue({
+ mseLivePlayback: false,
+ });
+
+ render();
+
+ expect(
+ screen.getByText(/browser doesn't support live video streaming/i)
+ ).toBeInTheDocument();
+ });
+
+ it('should play video on MEDIA_INFO event', async () => {
+ render();
+
+ const mediaInfoCallback = mockPlayer.on.mock.calls.find(
+ (call) => call[0] === mpegts.Events.MEDIA_INFO
+ )?.[1];
+
+ await mediaInfoCallback();
+
+ expect(mockPlayer.play).toHaveBeenCalled();
+ });
+ });
+
+ describe('VOD Player', () => {
+ beforeEach(() => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/video.mp4',
+ contentType: 'vod',
+ metadata: {
+ name: 'Test Movie',
+ year: '2024',
+ logo: { url: 'http://example.com/poster.jpg' },
+ },
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+ });
+
+ it('should use native video player for VOD', () => {
+ render();
+ expect(mpegts.createPlayer).not.toHaveBeenCalled();
+ });
+
+ it('should set video source for VOD', () => {
+ const { container } = render();
+ const video = container.querySelector('video');
+ expect(video).toBeInTheDocument();
+ expect(video.src).toBe('http://example.com/video.mp4');
+ expect(video.poster).toBe('http://example.com/poster.jpg');
+ });
+
+ it('should show metadata overlay', () => {
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ // Simulate video loaded event to clear loading state
+ fireEvent.loadedData(video);
+
+ expect(screen.getByText('Test Movie')).toBeInTheDocument();
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ });
+
+ it('should hide overlay after 4 seconds', () => {
+ vi.useFakeTimers();
+
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ fireEvent.loadedData(video);
+ fireEvent.canPlay(video);
+
+ expect(screen.getByText('Test Movie')).toBeInTheDocument();
+
+
+ vi.advanceTimersByTime(4000);
+
+ waitFor(() => {
+ expect(screen.queryByText('Test Movie')).not.toBeInTheDocument();
+ });
+
+ vi.useRealTimers();
+ });
+
+ it('should show overlay on mouse enter', () => {
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ fireEvent.loadedData(video);
+ fireEvent.canPlay(video);
+
+ const videoContainer = screen.getByText('Test Movie').closest('div');
+
+ fireEvent.mouseEnter(videoContainer);
+
+ expect(screen.getByText('Test Movie')).toBeInTheDocument();
+ });
+
+ it('should hide overlay on mouse leave', () => {
+ vi.useFakeTimers();
+
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ fireEvent.loadedData(video);
+ fireEvent.canPlay(video);
+
+ const videoContainer = screen.getByText('Test Movie').closest('div');
+
+ fireEvent.mouseEnter(videoContainer);
+ fireEvent.mouseLeave(videoContainer);
+
+ vi.advanceTimersByTime(4000);
+
+ waitFor(() => {
+ expect(screen.queryByText('Test Movie')).not.toBeInTheDocument();
+ });
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('Close functionality', () => {
+ beforeEach(() => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+ });
+
+ it('should call hideVideo when close button is clicked', () => {
+ vi.useFakeTimers();
+
+ render();
+
+ fireEvent.click(screen.getByTestId('close-button'));
+
+ vi.advanceTimersByTime(50);
+
+ waitFor(() => {
+ expect(mockHideVideo).toHaveBeenCalled();
+ expect(mockPlayer.destroy).toHaveBeenCalled();
+ });
+
+ vi.useRealTimers();
+ });
+ });
+
+ describe('Error handling', () => {
+ beforeEach(() => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/video.mp4',
+ contentType: 'vod',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+ });
+
+ it('should display video error messages', () => {
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ Object.defineProperty(video, 'error', {
+ value: { code: 3, message: 'MEDIA_ERR_DECODE' },
+ writable: true,
+ });
+
+ fireEvent.error(video);
+
+ expect(
+ screen.getByText(/MEDIA_ERR_DECODE/i)
+ ).toBeInTheDocument();
+ });
+
+ it('should handle network errors', () => {
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ Object.defineProperty(video, 'error', {
+ value: { code: 2, message: 'MEDIA_ERR_NETWORK' },
+ writable: true,
+ });
+
+ fireEvent.error(video);
+
+ expect(
+ screen.getByText(/MEDIA_ERR_NETWORK/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe('Player cleanup', () => {
+ it('should cleanup player on unmount', () => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+
+ const { unmount } = render();
+
+ unmount();
+
+ expect(mockPlayer.destroy).toHaveBeenCalled();
+ });
+
+ it('should cleanup player when streamUrl changes', () => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream1.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+
+ const { rerender } = render();
+
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream2.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+
+ rerender();
+
+ expect(mockPlayer.destroy).toHaveBeenCalled();
+ });
+ });
+
+ describe('Resize functionality', () => {
+ beforeEach(() => {
+ useVideoStore.mockImplementation((selector) => { {
+ const state = {
+ isVisible: true,
+ streamUrl: 'http://example.com/stream.ts',
+ contentType: 'live',
+ metadata: null,
+ hideVideo: mockHideVideo,
+ };
+ return selector ? selector(state) : state;
+ }});
+ });
+
+ it('should render resize handles', () => {
+ const { container } = render();
+ const handles = container.querySelectorAll(
+ '[class*="floating-video-no-drag"]'
+ );
+
+ // Should have 4 resize handles plus video element
+ expect(handles.length).toBeGreaterThanOrEqual(4);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/GuideRow.test.jsx b/frontend/src/components/__tests__/GuideRow.test.jsx
new file mode 100644
index 00000000..3f103305
--- /dev/null
+++ b/frontend/src/components/__tests__/GuideRow.test.jsx
@@ -0,0 +1,335 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import GuideRow from '../GuideRow';
+import {
+ CHANNEL_WIDTH,
+ EXPANDED_PROGRAM_HEIGHT,
+ HOUR_WIDTH,
+ PROGRAM_HEIGHT,
+} from '../../pages/guideUtils';
+
+// Mock logo import
+vi.mock('../../images/logo.png', () => ({
+ default: 'mocked-logo.png',
+}));
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ Play: (props) => ,
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Flex: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children}
,
+ };
+});
+
+describe('GuideRow', () => {
+ const mockChannel = {
+ id: 'channel-1',
+ name: 'Test Channel',
+ channel_number: '101',
+ logo_id: 'logo-1',
+ };
+
+ const mockProgram = {
+ id: 'program-1',
+ title: 'Test Program',
+ start_time: '2024-01-01T10:00:00Z',
+ end_time: '2024-01-01T11:00:00Z',
+ };
+
+ const mockLogos = {
+ 'logo-1': {
+ cache_url: 'https://example.com/logo.png',
+ },
+ };
+
+ const mockData = {
+ filteredChannels: [mockChannel],
+ programsByChannelId: new Map([[mockChannel.id, [mockProgram]]]),
+ expandedProgramId: null,
+ rowHeights: {},
+ logos: mockLogos,
+ hoveredChannelId: null,
+ setHoveredChannelId: vi.fn(),
+ renderProgram: vi.fn((program) => (
+
+ {program.title}
+
+ )),
+ handleLogoClick: vi.fn(),
+ contentWidth: 1920,
+ };
+
+ const mockStyle = {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render channel row with channel information', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('guide-row')).toBeInTheDocument();
+ expect(screen.getByAltText('Test Channel')).toBeInTheDocument();
+ expect(screen.getByText('101')).toBeInTheDocument();
+ });
+
+ it('should return null when channel does not exist', () => {
+ const data = { ...mockData, filteredChannels: [] };
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should use default logo when channel logo is not available', () => {
+ const channelWithoutLogo = { ...mockChannel, logo_id: 'missing-logo' };
+ const data = {
+ ...mockData,
+ filteredChannels: [channelWithoutLogo],
+ };
+
+ render();
+
+ const img = screen.getByAltText('Test Channel');
+ expect(img).toHaveAttribute('src', 'mocked-logo.png');
+ });
+
+ it('should display channel number or dash if missing', () => {
+ const channelWithoutNumber = { ...mockChannel, channel_number: null };
+ const data = {
+ ...mockData,
+ filteredChannels: [channelWithoutNumber],
+ };
+
+ render();
+
+ expect(screen.getByText('-')).toBeInTheDocument();
+ });
+ });
+
+ describe('Row Height Calculation', () => {
+ it('should use default PROGRAM_HEIGHT when no expanded program', () => {
+ render(
+
+ );
+
+ const row = screen.getByTestId('guide-row');
+ expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` });
+ });
+
+ it('should use EXPANDED_PROGRAM_HEIGHT when program is expanded', () => {
+ const data = {
+ ...mockData,
+ expandedProgramId: mockProgram.id,
+ };
+
+ render();
+
+ const row = screen.getByTestId('guide-row');
+ expect(row).toHaveStyle({ height: `${EXPANDED_PROGRAM_HEIGHT}px` });
+ });
+
+ it('should use pre-calculated row height from rowHeights array', () => {
+ const customHeight = 150;
+ const data = {
+ ...mockData,
+ rowHeights: { 0: customHeight },
+ };
+
+ render();
+
+ const row = screen.getByTestId('guide-row');
+ expect(row).toHaveStyle({ height: `${customHeight}px` });
+ });
+ });
+
+ describe('Programs Rendering', () => {
+ it('should render programs when channel has programs', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId(`program-${mockProgram.id}`)).toBeInTheDocument();
+ expect(screen.getByText('Test Program')).toBeInTheDocument();
+ expect(mockData.renderProgram).toHaveBeenCalledWith(
+ mockProgram,
+ undefined,
+ mockChannel
+ );
+ });
+
+ it('should render multiple programs', () => {
+ const programs = [
+ mockProgram,
+ { ...mockProgram, id: 'program-2', title: 'Another Program' },
+ ];
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ };
+
+ render();
+
+ expect(screen.getByText('Test Program')).toBeInTheDocument();
+ expect(screen.getByText('Another Program')).toBeInTheDocument();
+ expect(mockData.renderProgram).toHaveBeenCalledTimes(2);
+ });
+
+ it('should render placeholder when channel has no programs', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ };
+
+ render();
+
+ const placeholders = screen.getAllByText('No program data');
+ expect(placeholders.length).toBe(Math.ceil(24 / 2));
+ });
+
+ it('should render placeholder when programsByChannelId does not contain channel', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map(),
+ };
+
+ render();
+
+ const placeholders = screen.getAllByText('No program data');
+ expect(placeholders.length).toBe(Math.ceil(24 / 2));
+ });
+
+ it('should position placeholder programs correctly', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ };
+
+ const { container } = render(
+
+ );
+
+ const placeholders = container.querySelectorAll('[pos*="absolute"]');
+ const filteredPlaceholders = Array.from(placeholders).filter(el =>
+ el.textContent.includes('No program data')
+ );
+
+ filteredPlaceholders.forEach((placeholder, index) => {
+ expect(placeholder).toHaveAttribute('left', `${index * (HOUR_WIDTH * 2)}`);
+ expect(placeholder).toHaveAttribute('w', `${HOUR_WIDTH * 2}`);
+ });
+ });
+ });
+
+ describe('Channel Logo Interactions', () => {
+ it('should call handleLogoClick when logo is clicked', () => {
+ render(
+
+ );
+
+ const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
+ fireEvent.click(logo);
+
+ expect(mockData.handleLogoClick).toHaveBeenCalledWith(
+ mockChannel,
+ expect.any(Object)
+ );
+ });
+
+ it('should show play icon on hover', () => {
+ const data = {
+ ...mockData,
+ hoveredChannelId: mockChannel.id,
+ };
+
+ render();
+
+ expect(screen.getByTestId('play-icon')).toBeInTheDocument();
+ });
+
+ it('should not show play icon when not hovering', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
+ });
+
+ it('should call setHoveredChannelId on mouse enter', () => {
+ render(
+
+ );
+
+ const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
+ fireEvent.mouseEnter(logo);
+
+ expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(mockChannel.id);
+ });
+
+ it('should call setHoveredChannelId with null on mouse leave', () => {
+ render(
+
+ );
+
+ const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
+ fireEvent.mouseLeave(logo);
+
+ expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('Layout and Styling', () => {
+ it('should set correct channel logo width', () => {
+ const { container } = render(
+
+ );
+
+ const logoContainer = container.querySelector('.channel-logo');
+ expect(logoContainer).toHaveAttribute('w', `${CHANNEL_WIDTH}`);
+ expect(logoContainer).toHaveAttribute('miw', `${CHANNEL_WIDTH}`);
+ });
+
+ it('should apply content width to row', () => {
+ const customWidth = 2400;
+ const data = {
+ ...mockData,
+ contentWidth: customWidth,
+ };
+
+ render();
+
+ const row = screen.getByTestId('guide-row');
+ expect(row).toHaveStyle({ width: `${customWidth}px` });
+ });
+
+ it('should adjust logo image container height based on row height', () => {
+ const customHeight = 200;
+ const data = {
+ ...mockData,
+ rowHeights: { 0: customHeight },
+ };
+
+ const { container } = render(
+
+ );
+
+ const imageContainer = container.querySelector('img').parentElement;
+ expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/HourTimeline.test.jsx b/frontend/src/components/__tests__/HourTimeline.test.jsx
new file mode 100644
index 00000000..372ec155
--- /dev/null
+++ b/frontend/src/components/__tests__/HourTimeline.test.jsx
@@ -0,0 +1,318 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import HourTimeline from '../HourTimeline';
+import { format } from '../../utils/dateTimeUtils';
+import { HOUR_WIDTH } from '../../pages/guideUtils';
+
+// Mock date utilities
+vi.mock('../../utils/dateTimeUtils', () => ({
+ format: vi.fn((date, formatStr) => {
+ if (!formatStr) return date.toISOString();
+ return formatStr === 'h:mm a' ? '12:00 PM' : 'Jan 1';
+ }),
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children},
+ };
+});
+
+describe('HourTimeline', () => {
+ const mockTime1 = new Date('2024-01-01T10:00:00Z');
+ const mockTime2 = new Date('2024-01-01T11:00:00Z');
+ const mockTime3 = new Date('2024-01-02T00:00:00Z');
+
+ const mockHourTimeline = [
+ { time: mockTime1, isNewDay: false },
+ { time: mockTime2, isNewDay: false },
+ ];
+
+ const mockTimeFormat = 'h:mm a';
+ const mockFormatDayLabel = vi.fn((time) => 'Mon');
+ const mockHandleTimeClick = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render all hour blocks', () => {
+ render(
+
+ );
+
+ expect(mockFormatDayLabel).toHaveBeenCalledTimes(2);
+ expect(format).toHaveBeenCalledWith(mockTime1, mockTimeFormat);
+ expect(format).toHaveBeenCalledWith(mockTime2, mockTimeFormat);
+ });
+
+ it('should render empty when hourTimeline is empty', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render formatted time labels', () => {
+ render(
+
+ );
+
+ const timeLabels = screen.getAllByText('12:00 PM');
+ expect(timeLabels.length).toBe(2);
+ });
+
+ it('should render day labels', () => {
+ render(
+
+ );
+
+ const dayLabels = screen.getAllByText('Mon');
+ expect(dayLabels.length).toBe(2);
+ });
+ });
+
+ describe('HourBlock Styling', () => {
+ it('should apply correct width and height to hour blocks', () => {
+ const { container } = render(
+
+ );
+
+ const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
+ hourBlocks.forEach((block) => {
+ expect(block).toHaveAttribute('w', `${HOUR_WIDTH}`);
+ expect(block).toHaveAttribute('h', '40px');
+ expect(block).toHaveAttribute('pos', 'relative');
+ });
+ });
+
+ it('should apply default styling for non-new-day blocks', () => {
+ const { container } = render(
+
+ );
+
+ const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
+ expect(hourBlocks[0]).toHaveStyle({
+ backgroundColor: '#1B2421',
+ });
+ });
+
+ it('should apply special styling for new day blocks', () => {
+ const newDayTimeline = [
+ { time: mockTime3, isNewDay: true },
+ ];
+
+ const { container } = render(
+
+ );
+
+ const hourBlock = container.querySelector('[style*="cursor: pointer"]');
+ expect(hourBlock).toHaveStyle({
+ borderLeft: '2px solid #3BA882',
+ backgroundColor: '#1E2A27',
+ });
+ });
+
+ it('should apply bold font weight to day label on new day', () => {
+ const newDayTimeline = [
+ { time: mockTime3, isNewDay: true },
+ ];
+
+ const { container } = render(
+
+ );
+
+ const dayLabel = screen.getByText('Mon');
+ expect(dayLabel).toHaveAttribute('fw', '600');
+ expect(dayLabel).toHaveAttribute('c', '#3BA882');
+ });
+
+ it('should apply normal font weight to day label on regular day', () => {
+ const { container } = render(
+
+ );
+
+ const dayLabels = screen.getAllByText('Mon');
+ expect(dayLabels[0]).toHaveAttribute('fw', '400');
+ });
+ });
+
+ describe('Quarter Hour Markers', () => {
+ it('should render quarter hour markers', () => {
+ const { container } = render(
+
+ );
+
+ const markers = container.querySelectorAll('[style*="background-color: rgb(113, 128, 150);"]');
+ expect(markers.length).toBe(3); // 15, 30, 45 minute markers
+ });
+
+ it('should position quarter hour markers correctly', () => {
+ const { container } = render(
+
+ );
+
+ const markers = container.querySelectorAll('[style*="backgroundColor: #718096"]');
+ const positions = ['25%', '50%', '75%'];
+
+ markers.forEach((marker, index) => {
+ expect(marker).toHaveStyle({
+ left: positions[index],
+ width: '1px',
+ height: '8px',
+ position: 'absolute',
+ bottom: '0px',
+ });
+ });
+ });
+ });
+
+ describe('Click Interactions', () => {
+ it('should call handleTimeClick when hour block is clicked', () => {
+ const { container } = render(
+
+ );
+
+ const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
+ fireEvent.click(hourBlocks[0]);
+
+ expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
+ });
+
+ it('should call handleTimeClick with correct time for each block', () => {
+ const { container } = render(
+
+ );
+
+ const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
+
+ fireEvent.click(hourBlocks[0]);
+ expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
+
+ fireEvent.click(hourBlocks[1]);
+ expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime2, expect.any(Object));
+ });
+ });
+
+ describe('Component Keys', () => {
+ it('should use formatted time as key for each hour block', () => {
+ format.mockImplementation((date) => date.toISOString());
+
+ const { container } = render(
+
+ );
+
+ expect(format).toHaveBeenCalledWith(mockTime1);
+ expect(format).toHaveBeenCalledWith(mockTime2);
+ });
+ });
+
+ describe('Time Label Positioning', () => {
+ it('should position time label correctly', () => {
+ const { container } = render(
+
+ );
+
+ const timeLabel = container.querySelector('[pos*="absolute"][top*="8"]');
+ expect(timeLabel).toHaveAttribute('left', '4');
+ });
+ });
+
+ describe('Visual Separators', () => {
+ it('should render left separator box', () => {
+ const { container } = render(
+
+ );
+
+ const separator = container.querySelector('[w*="1px"][left*="0"]');
+ expect(separator).toHaveAttribute('pos', 'absolute');
+ expect(separator).toHaveAttribute('top', '0');
+ expect(separator).toHaveAttribute('bottom', '0');
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/LazyLogo.test.jsx b/frontend/src/components/__tests__/LazyLogo.test.jsx
new file mode 100644
index 00000000..dfd9d27c
--- /dev/null
+++ b/frontend/src/components/__tests__/LazyLogo.test.jsx
@@ -0,0 +1,398 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import LazyLogo from '../LazyLogo';
+import useLogosStore from '../../store/logos';
+
+// Mock the logos store
+vi.mock('../../store/logos', () => ({
+ default: vi.fn(),
+}));
+
+// Mock the logo import
+vi.mock('../../images/logo.png', () => ({
+ default: 'mocked-default-logo.png',
+}));
+
+// Mock Mantine Skeleton component
+vi.mock('@mantine/core', async () => {
+ return {
+ Skeleton: ({ height, width, style, ...props }) => {
+ return ;
+ },
+ };
+});
+
+describe('LazyLogo', () => {
+ let mockFetchLogosByIds;
+ let mockStore;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.clearAllTimers();
+ vi.useFakeTimers();
+
+ mockFetchLogosByIds = vi.fn().mockResolvedValue(undefined);
+
+ mockStore = {
+ logos: {},
+ fetchLogosByIds: mockFetchLogosByIds,
+ allowLogoRendering: true,
+ };
+
+ useLogosStore.mockImplementation((selector) => selector(mockStore));
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ });
+
+ describe('Rendering', () => {
+ it('should render image with logo data', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ render();
+
+ const img = screen.getByAltText('Test Logo');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'https://example.com/logo.png');
+ });
+
+ it('should render with default style', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ render();
+
+ const img = screen.getByAltText('logo');
+ expect(img).toHaveStyle({
+ maxHeight: '18px',
+ maxWidth: '55px',
+ });
+ });
+
+ it('should render with custom style', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ const customStyle = { maxHeight: 30, maxWidth: 100 };
+ render();
+
+ const img = screen.getByAltText('logo');
+ expect(img).toHaveStyle({
+ maxHeight: '30px',
+ maxWidth: '100px',
+ });
+ });
+
+ it('should render fallback logo when no logoId provided', () => {
+ render();
+
+ const img = screen.getByAltText('logo');
+ expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
+ });
+
+ it('should pass through additional props to img element', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ render(
+
+ );
+
+ const img = screen.getByTestId('custom-logo');
+ expect(img).toHaveClass('test-class');
+ });
+ });
+
+ describe('Skeleton Loading State', () => {
+ it('should show skeleton when logo rendering is not allowed', () => {
+ mockStore.allowLogoRendering = false;
+
+ render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ expect(screen.queryByAltText('logo')).not.toBeInTheDocument();
+ });
+
+ it('should show skeleton when logo data is not available', () => {
+ mockStore.logos = {};
+
+ render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ });
+
+ it('should render skeleton with default dimensions', () => {
+ mockStore.logos = {};
+
+ render();
+
+ const skeleton = screen.getByTestId('skeleton');
+ expect(skeleton).toHaveStyle({
+ height: '18px',
+ width: '55px',
+ });
+ });
+
+ it('should render skeleton with custom dimensions', () => {
+ mockStore.logos = {};
+
+ const customStyle = { maxHeight: 40, maxWidth: 120 };
+ render();
+
+ const skeleton = screen.getByTestId('skeleton');
+ expect(skeleton).toHaveStyle({
+ height: '40px',
+ width: '120px',
+ });
+ });
+
+ it('should apply border radius to skeleton', () => {
+ mockStore.logos = {};
+
+ render();
+
+ const skeleton = screen.getByTestId('skeleton');
+ expect(skeleton).toHaveStyle({
+ borderRadius: '4px',
+ });
+ });
+ });
+
+ describe('Logo Fetching', () => {
+ it('should fetch logo when logoId is provided and logo data is missing', async () => {
+ mockStore.logos = {};
+
+ render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
+ });
+
+ it('should not fetch logo when logo data already exists', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch logo when allowLogoRendering is false', () => {
+ mockStore.allowLogoRendering = false;
+ mockStore.logos = {};
+
+ render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch logo when no logoId is provided', () => {
+ render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+ });
+
+ it('should batch multiple logo requests', async () => {
+ mockStore.logos = {};
+
+ render(
+ <>
+
+
+
+ >
+ );
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(
+ expect.arrayContaining(['logo-1', 'logo-2', 'logo-3'])
+ );
+ });
+
+ it('should debounce fetch requests with 100ms delay', async () => {
+ mockStore.logos = {};
+
+ render();
+
+ vi.advanceTimersByTime(50);
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(50);
+ expect(mockFetchLogosByIds).toHaveBeenCalled();
+ });
+
+ it('should handle fetch errors gracefully', async () => {
+ mockFetchLogosByIds.mockRejectedValueOnce(new Error('Fetch failed'));
+ mockStore.logos = {};
+
+ render();
+
+ vi.advanceTimersByTime(100);
+ });
+
+ it('should not fetch same logo twice', async () => {
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
+
+ rerender();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should fallback to default logo on image load error', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
+ };
+
+ render();
+
+ const img = screen.getByAltText('logo');
+
+ // Simulate image load error
+ img.dispatchEvent(new Event('error'));
+
+ expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
+ });
+
+ it('should use custom fallback source on error', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
+ };
+
+ const customFallback = 'custom-fallback.png';
+ render();
+
+ const img = screen.getByAltText('logo');
+
+ img.dispatchEvent(new Event('error'));
+
+ expect(img).toHaveAttribute('src', customFallback);
+ });
+
+ it('should only set fallback once to prevent infinite error loop', () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
+ };
+
+ render();
+
+ const img = screen.getByAltText('logo');
+
+ // First error - should set fallback
+ img.dispatchEvent(new Event('error'));
+ expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
+
+ // Reset src to test second error
+ img.src = 'https://example.com/another-invalid.png';
+
+ // Second error - should not change src again
+ img.dispatchEvent(new Event('error'));
+ expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
+ });
+
+ it('should reset error state when logoId changes', async () => {
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo1.png' },
+ 'logo-2': { cache_url: 'https://example.com/logo2.png' },
+ };
+
+ const { rerender } = render();
+
+ const img = screen.getByAltText('logo');
+ img.dispatchEvent(new Event('error'));
+
+ rerender();
+
+ const newImg = screen.getByAltText('logo');
+ expect(newImg).toHaveAttribute('src', 'https://example.com/logo2.png');
+ });
+ });
+
+ describe('Component Lifecycle', () => {
+ it('should cleanup on unmount', () => {
+ const { unmount } = render();
+
+ unmount();
+
+ // Should not throw errors after unmount
+ vi.advanceTimersByTime(100);
+ });
+
+ it('should handle rapid logoId changes', async () => {
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ rerender();
+ rerender();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalled();
+ });
+ });
+
+ describe('Store Integration', () => {
+ it('should react to store updates', async () => {
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+
+ // Update store with logo data
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ rerender();
+
+ expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
+ expect(screen.getByAltText('logo')).toBeInTheDocument();
+ });
+
+ it('should handle allowLogoRendering becoming true', async () => {
+ mockStore.allowLogoRendering = false;
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+
+ mockStore.allowLogoRendering = true;
+ rerender();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
new file mode 100644
index 00000000..14be3a7f
--- /dev/null
+++ b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
@@ -0,0 +1,712 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { BrowserRouter } from 'react-router-dom';
+import M3URefreshNotification from '../M3URefreshNotification';
+import usePlaylistsStore from '../../store/playlists';
+import useStreamsStore from '../../store/streams';
+import useChannelsStore from '../../store/channels';
+import useEPGsStore from '../../store/epgs';
+import useVODStore from '../../store/useVODStore';
+import API from '../../api';
+import { showNotification } from '../../utils/notificationUtils';
+
+// Mock all stores
+vi.mock('../../store/playlists', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../store/streams', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../store/channels', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../store/epgs', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../store/useVODStore', () => ({
+ default: vi.fn(),
+}));
+
+// Mock API
+vi.mock('../../api', () => ({
+ default: {
+ refreshPlaylist: vi.fn(),
+ requeryChannels: vi.fn(),
+ },
+}));
+
+// Mock notification utility
+vi.mock('../../utils/notificationUtils', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('@mantine/core', async () => {
+ return {
+ Stack: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Button: ({ children, onClick }) => (
+
+ ),
+ };
+});
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ CircleCheck: () => ,
+}));
+
+const renderWithProviders = (component) => {
+ return render(
+ {component}
+ );
+};
+
+describe('M3URefreshNotification', () => {
+ let mockPlaylistsStore;
+ let mockStreamsStore;
+ let mockChannelsStore;
+ let mockEPGsStore;
+ let mockVODStore;
+
+ const mockPlaylist = {
+ id: 1,
+ name: 'Test Playlist',
+ url: 'https://example.com/playlist.m3u',
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Setup default store mocks
+ mockPlaylistsStore = {
+ playlists: [mockPlaylist],
+ refreshProgress: {},
+ fetchPlaylists: vi.fn(),
+ setEditPlaylistId: vi.fn(),
+ };
+
+ mockStreamsStore = {
+ fetchStreams: vi.fn(),
+ };
+
+ mockChannelsStore = {
+ fetchChannelGroups: vi.fn(),
+ fetchChannels: vi.fn(),
+ };
+
+ mockEPGsStore = {
+ fetchEPGData: vi.fn(),
+ };
+
+ mockVODStore = {
+ fetchCategories: vi.fn(),
+ };
+
+ usePlaylistsStore.mockImplementation((selector) => selector(mockPlaylistsStore));
+ useStreamsStore.mockImplementation((selector) => selector(mockStreamsStore));
+ useChannelsStore.mockImplementation((selector) => selector(mockChannelsStore));
+ useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore));
+ useVODStore.mockImplementation((selector) => selector(mockVODStore));
+ });
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ const { container } = renderWithProviders();
+ expect(container).toBeInTheDocument();
+ });
+
+ it('should render empty fragment', () => {
+ const { container } = renderWithProviders();
+ expect(container.firstChild).toBeNull();
+ });
+ });
+
+ describe('Download Progress Notifications', () => {
+ it('should show notification when download starts', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'downloading',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'Downloading starting...',
+ loading: true,
+ autoClose: 2000,
+ icon: null,
+ });
+ });
+ });
+
+ it('should show notification when download completes', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'downloading',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'Downloading complete!',
+ loading: false,
+ autoClose: 2000,
+ icon: expect.anything(),
+ });
+ });
+ });
+
+ it('should not show notification for intermediate progress', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'downloading',
+ progress: 50,
+ status: 'in_progress',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Parsing Progress Notifications', () => {
+ it('should show notification when parsing starts', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'Stream parsing starting...',
+ loading: true,
+ autoClose: 2000,
+ icon: null,
+ });
+ });
+ });
+
+ it('should show notification and trigger fetches when parsing completes', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalled();
+ expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
+ expect(API.requeryChannels).toHaveBeenCalled();
+ expect(mockChannelsStore.fetchChannels).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Group Processing Notifications', () => {
+ it('should show notification when processing groups starts', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'processing_groups',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'Group parsing starting...',
+ loading: true,
+ autoClose: 2000,
+ icon: null,
+ });
+ });
+ });
+
+ it('should trigger multiple fetches when processing groups completes', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'processing_groups',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
+ expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
+ expect(mockEPGsStore.fetchEPGData).toHaveBeenCalled();
+ expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('VOD Refresh Notifications', () => {
+ it('should show notification when VOD refresh starts', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'vod_refresh',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'VOD content refresh starting...',
+ loading: true,
+ autoClose: 2000,
+ icon: null,
+ });
+ });
+ });
+
+ it('should trigger VOD-specific fetches when VOD refresh completes', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'vod_refresh',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
+ expect(mockVODStore.fetchCategories).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Pending Setup Status', () => {
+ it('should show setup notification and trigger fetches for pending_setup status', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ status: 'pending_setup',
+ message: 'Test setup message',
+ progress: 100,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Setup: Test Playlist',
+ message: expect.anything(),
+ color: 'orange.5',
+ autoClose: 5000,
+ });
+ expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
+ expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
+ });
+ });
+
+ it('should use default message when no message provided in pending_setup', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ status: 'pending_setup',
+ progress: 100,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should show error notification when status is error and progress is 100', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ status: 'error',
+ progress: 100,
+ error: 'Connection timeout',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'parsing failed: Connection timeout',
+ color: 'red',
+ autoClose: 5000,
+ });
+ });
+ });
+
+ it('should not show error notification when progress is not 100', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ status: 'error',
+ progress: 50,
+ error: 'Connection timeout',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should use default error message when error field is missing', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'downloading',
+ status: 'error',
+ progress: 100,
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'downloading failed: Unknown error',
+ color: 'red',
+ autoClose: 5000,
+ });
+ });
+ });
+
+ it('should use default action when action field is missing in error', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ status: 'error',
+ progress: 100,
+ error: 'Test error',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith({
+ title: 'M3U Processing: Test Playlist',
+ message: 'Processing failed: Test error',
+ color: 'red',
+ autoClose: 5000,
+ });
+ });
+ });
+
+ it('should not show further notifications after error status', async () => {
+ // First update with error
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ status: 'error',
+ progress: 100,
+ error: 'Test error',
+ },
+ };
+
+ const { rerender } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+
+ vi.clearAllMocks();
+
+ // Second update with success
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ status: 'completed',
+ progress: 100,
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ // Should not show notification due to previous error
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Playlist Validation', () => {
+ it('should not show notification if playlist not found', async () => {
+ mockPlaylistsStore.playlists = [];
+ mockPlaylistsStore.refreshProgress = {
+ 999: {
+ account: 999,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should handle multiple playlists correctly', async () => {
+ const secondPlaylist = { id: 2, name: 'Second Playlist' };
+ mockPlaylistsStore.playlists = [mockPlaylist, secondPlaylist];
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ 2: {
+ account: 2,
+ action: 'downloading',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(2);
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'M3U Processing: Test Playlist',
+ })
+ );
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'M3U Processing: Second Playlist',
+ })
+ );
+ });
+ });
+ });
+
+ describe('Notification Deduplication', () => {
+ it('should not show duplicate notification for same status', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ const { rerender } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+
+ vi.clearAllMocks();
+
+ // Re-render with same data
+ rerender(
+
+
+
+ );
+
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+
+ it('should show notification when status changes', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ const { rerender } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+
+ vi.clearAllMocks();
+
+ // Update with different progress
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('State Cleanup', () => {
+ it('should reset notification status when playlists change', async () => {
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ const { rerender } = renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalled();
+ });
+
+ vi.clearAllMocks();
+
+ // Change playlists - remove existing playlist
+ mockPlaylistsStore.playlists = [];
+ mockPlaylistsStore.refreshProgress = {
+ 2: {
+ account: 2,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ // Should not show notification because playlist doesn't exist
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+
+ it('should handle empty playlists array', async () => {
+ mockPlaylistsStore.playlists = [];
+ mockPlaylistsStore.refreshProgress = {};
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Effect Dependencies', () => {
+ it('should re-run effect when refreshProgress changes', async () => {
+ mockPlaylistsStore.refreshProgress = {};
+
+ const { rerender } = renderWithProviders();
+
+ expect(showNotification).not.toHaveBeenCalled();
+
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalled();
+ });
+ });
+
+ it('should re-run effect when playlists change', async () => {
+ const { rerender } = renderWithProviders();
+
+ const newPlaylist = { id: 2, name: 'New Playlist' };
+ mockPlaylistsStore.playlists = [mockPlaylist, newPlaylist];
+
+ rerender(
+
+
+
+ );
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/RecordingSynopsis.test.jsx b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx
new file mode 100644
index 00000000..482d7e41
--- /dev/null
+++ b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx
@@ -0,0 +1,169 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import RecordingSynopsis from '../RecordingSynopsis';
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => {
+ return (
+
+ {children}
+
+ );
+ },
+ };
+});
+
+describe('RecordingSynopsis', () => {
+ let mockOnOpen;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOnOpen = vi.fn();
+ });
+
+ describe('Rendering', () => {
+ it('should render with short description', () => {
+ const shortDescription = 'This is a short description.';
+
+ render(
+
+ );
+
+ const text = screen.getByText(shortDescription);
+ expect(text).toBeInTheDocument();
+ });
+
+ it('should return null when description is undefined', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should return null when description is null', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should return null when description is empty string', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render without onOpen callback', () => {
+ const description = 'Test description';
+
+ render();
+
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+ });
+
+ describe('Text Truncation', () => {
+ it('should not truncate description with exactly 140 characters', () => {
+ const exactLength = 'A'.repeat(140);
+
+ render(
+
+ );
+
+ expect(screen.getByText(exactLength)).toBeInTheDocument();
+ expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument();
+ });
+
+ it('should truncate description with 141 characters', () => {
+ const overLength = 'A'.repeat(141);
+
+ render(
+
+ );
+
+ const expectedPreview = `${overLength.slice(0, 140).trim()}...`;
+ expect(screen.getByText(expectedPreview)).toBeInTheDocument();
+ });
+
+ it('should trim whitespace before adding ellipsis', () => {
+ const description = 'A'.repeat(135) + ' B'.repeat(10);
+
+ render(
+
+ );
+
+ const preview = screen.getByTestId('text').textContent;
+ expect(preview).toMatch(/\.\.\./);
+ expect(preview).not.toMatch(/\s+\.\.\./);
+ });
+ });
+
+ describe('Click Interactions', () => {
+ it('should call onOpen when clicked', () => {
+ const description = 'Test description';
+
+ render(
+
+ );
+
+ const text = screen.getByText(description);
+ fireEvent.click(text);
+
+ expect(mockOnOpen).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not throw error when clicked without onOpen callback', () => {
+ const description = 'Test description';
+
+ render();
+
+ const text = screen.getByText(description);
+ expect(() => fireEvent.click(text)).not.toThrow();
+ });
+
+ it('should handle multiple clicks', () => {
+ const description = 'Test description';
+
+ render(
+
+ );
+
+ const text = screen.getByText(description);
+
+ fireEvent.click(text);
+ fireEvent.click(text);
+ fireEvent.click(text);
+
+ expect(mockOnOpen).toHaveBeenCalledTimes(3);
+ });
+
+ it('should be clickable with truncated text', () => {
+ const longDescription = 'A'.repeat(200);
+
+ render(
+
+ );
+
+ const text = screen.getByTestId('text');
+ fireEvent.click(text);
+
+ expect(mockOnOpen).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/SeriesModal.test.jsx b/frontend/src/components/__tests__/SeriesModal.test.jsx
new file mode 100644
index 00000000..acbd35c1
--- /dev/null
+++ b/frontend/src/components/__tests__/SeriesModal.test.jsx
@@ -0,0 +1,864 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import SeriesModal from '../SeriesModal';
+import useVODStore from '../../store/useVODStore';
+import useVideoStore from '../../store/useVideoStore';
+import useSettingsStore from '../../store/settings';
+import { copyToClipboard } from '../../utils';
+
+// Mock stores
+vi.mock('../../store/useVODStore', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../store/useVideoStore', () => ({
+ default: vi.fn(),
+}));
+
+vi.mock('../../store/settings', () => ({
+ default: vi.fn(),
+}));
+
+// Mock utils
+vi.mock('../../utils', () => ({
+ copyToClipboard: vi.fn(),
+}));
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ Play: () => ,
+ Copy: () => ,
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ const actual = await vi.importActual('@mantine/core');
+
+ return {
+ ...actual,
+ Modal: ({ opened, onClose, title, children, size, ...props }) => {
+ if (!opened) return null;
+ return (
+
+ );
+ },
+ Box: ({ children, ...props }) => {children}
,
+ Button: ({ children, onClick, disabled, ...props }) => (
+
+ ),
+ Flex: ({ children, ...props }) => {children}
,
+ Group: ({ children, ...props }) => {children}
,
+ Image: ({ src, alt, ...props }) => (
+
+ ),
+ Text: ({ children, ...props }) => {children}
,
+ Title: ({ children, order, ...props }) => (
+ {children}
+ ),
+ Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
+
+
+
+ ),
+ Badge: ({ children, ...props }) => {children},
+ Loader: (props) => ,
+ Stack: ({ children, ...props }) => {children}
,
+ ActionIcon: ({ children, onClick, disabled, ...props }) => (
+
+ ),
+ Tabs: ({ children, value, onChange, ...props }) => (
+
+
{
+ const tab = e.target.closest('[data-tab-value]');
+ if (tab) onChange?.(tab.dataset.tabValue);
+ }}>
+ {children}
+
+
+ ),
+ TabsList: ({ children }) => {children}
,
+ TabsTab: ({ children, value }) => (
+
+ ),
+ TabsPanel: ({ children, value }) => (
+ {children}
+ ),
+ Table: ({ children, ...props }) => ,
+ TableThead: ({ children }) => {children},
+ TableTbody: ({ children }) => {children},
+ TableTr: ({ children, onClick, ...props }) => (
+ {children}
+ ),
+ TableTh: ({ children, ...props }) => {children} | ,
+ TableTd: ({ children, ...props }) => {children} | ,
+ Divider: (props) =>
,
+ };
+});
+
+describe('SeriesModal', () => {
+ let mockVODStore;
+ let mockVideoStore;
+ let mockSettingsStore;
+
+ const mockSeries = {
+ id: 1,
+ name: 'Test Series',
+ series_image: 'https://example.com/cover.jpg',
+ genre: 'Drama, Action',
+ cast: 'Actor 1, Actor 2',
+ director: 'Director Name',
+ m3u_account: { name: 'Test Account' },
+ rating: '8.5',
+ release_date: '2020-01-01',
+ youtube_trailer: 'dQw4w9WgXcQ',
+ };
+
+ const mockEpisode = {
+ id: 1,
+ uuid: 'episode-uuid-1',
+ series_id: 1,
+ season_number: 1,
+ episode_number: 1,
+ name: 'Pilot',
+ duration_secs: 3600,
+ rating: '8.0',
+ container_extension: 'mkv',
+ added: '2024-01-01T00:00:00Z',
+ };
+
+ const mockDetailedSeries = {
+ ...mockSeries,
+ episodesList: [mockEpisode],
+ tmdb_id: '12345',
+ imdb_id: 'tt1234567',
+ };
+
+ const mockProviders = [
+ {
+ id: 1,
+ stream_id: 100,
+ account_id: 5,
+ m3u_account: { name: 'Provider 1' },
+ stream_name: 'Test Series 1080p',
+ quality_info: { quality: '1080p' },
+ },
+ {
+ id: 2,
+ stream_id: 101,
+ account_id: 5,
+ m3u_account: { name: 'Provider 2' },
+ stream_name: 'Test Series 720p',
+ quality_info: null,
+ }
+ ];
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockVODStore = {
+ fetchSeriesInfo: vi.fn().mockResolvedValue(mockDetailedSeries),
+ fetchSeriesProviders: vi.fn().mockResolvedValue(mockProviders),
+ };
+
+ mockVideoStore = {
+ showVideo: vi.fn(),
+ };
+
+ mockSettingsStore = {
+ environment: { env_mode: 'prod' },
+ };
+
+ useVODStore.mockImplementation((selector) => selector ? selector(mockVODStore) : mockVODStore);
+ useVideoStore.mockImplementation((selector) => selector ? selector(mockVideoStore) : mockVideoStore);
+ useSettingsStore.mockImplementation((selector) => selector ? selector(mockSettingsStore) : mockSettingsStore);
+
+ copyToClipboard.mockResolvedValue(undefined);
+ });
+
+ describe('Rendering', () => {
+ it('should render nothing when series is null', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render nothing when modal is closed', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should render modal when opened with series', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+ });
+
+ it('should display series name as title', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Series')).toBeInTheDocument();
+ });
+ });
+
+ it('should display cover image', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const image = screen.getByAltText('Test Series');
+ expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
+ });
+ });
+
+ it('should show loader while fetching details', () => {
+ mockVODStore.fetchSeriesInfo = vi.fn(() => new Promise(() => {}));
+
+ render(
+
+ );
+
+ //expect multiple loader instances since we have one for details and one for providers
+ const loaders = screen.getAllByTestId('loader');
+ expect(loaders.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Data Fetching', () => {
+ it('should fetch series info when opened', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('should fetch series providers when opened', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesProviders).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('should not fetch data when modal is closed', () => {
+ render(
+
+ );
+
+ expect(mockVODStore.fetchSeriesInfo).not.toHaveBeenCalled();
+ expect(mockVODStore.fetchSeriesProviders).not.toHaveBeenCalled();
+ });
+
+ it('should reset state when modal closes', async () => {
+ const { rerender } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
+ });
+
+ rerender(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Series Information Display', () => {
+ it('should display genre', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Drama, Action/)).toBeInTheDocument();
+ });
+ });
+
+ it('should display rating', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/8\.5/)).toBeInTheDocument();
+ });
+ });
+
+ it('should display IMDB link when imdb_id exists', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const link = screen.getByText(/IMDB/i).closest('a');
+ expect(link).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
+ });
+ });
+
+ it('should display TMDB link when tmdb_id exists', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const link = screen.getByText(/TMDB/i).closest('a');
+ expect(link).toHaveAttribute('href', 'https://www.themoviedb.org/tv/12345');
+ });
+ });
+ });
+
+ describe('Provider Selection', () => {
+ it('should display provider select with fetched providers', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const select = screen.getByTestId('select');
+ expect(select).toBeInTheDocument();
+ });
+ });
+
+ it('should format provider label correctly with quality info', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle provider selection', async () => {
+ render(
+
+ );
+
+ let select;
+ await waitFor(() => {
+ select = screen.getByTestId('select').querySelector('select');
+ fireEvent.change(select, { target: { value: '1' } });
+ });
+
+ await waitFor(() => {
+ expect(select.value).toBe('1');
+ });
+ });
+
+ it('should show loader while fetching providers', () => {
+ mockVODStore.fetchSeriesProviders = vi.fn(() => new Promise(() => {}));
+
+ render(
+
+ );
+
+ // Should show loading text and loader
+ expect(screen.getByText('Stream Selection')).toBeInTheDocument();
+ const loaders = screen.getAllByTestId('loader');
+ expect(loaders.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('Episodes Display', () => {
+ it('should display episodes grouped by season', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should display episode information', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Pilot/)).toBeInTheDocument();
+ });
+ });
+
+ it('should format episode duration correctly', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/1h 0m/)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle episodes with no duration', async () => {
+ const episodeNoDuration = { ...mockEpisode, duration_secs: null };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [episodeNoDuration],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Pilot/)).toBeInTheDocument();
+ });
+ });
+
+ it('should sort episodes by episode number', async () => {
+ const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [episode2, mockEpisode],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const episodes = screen.getAllByTestId('table-tr');
+ expect(episodes[1]).toHaveTextContent('Pilot');
+ expect(episodes[2]).toHaveTextContent('Second Episode');
+ });
+ });
+ });
+
+ describe('Episode Actions', () => {
+ it('should play episode when play button clicked', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const playButtons = screen.getAllByTestId('action-icon');
+ fireEvent.click(playButtons[0]);
+ });
+
+ expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
+ expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
+ 'vod',
+ mockEpisode
+ );
+ });
+
+ it('should include provider stream_id in URL when provider selected', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const select = screen.getByTestId('select').querySelector('select');
+ fireEvent.change(select, { target: { value: '1' } });
+ });
+
+ await waitFor(() => {
+ const playButtons = screen.getAllByTestId('action-icon');
+ fireEvent.click(playButtons[0]);
+ });
+
+ expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
+ expect.stringContaining('stream_id=100'),
+ 'vod',
+ mockEpisode
+ );
+ });
+
+ it('should use dev mode URL in development', async () => {
+ mockSettingsStore.environment.env_mode = 'dev';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const playButtons = screen.getAllByTestId('action-icon');
+ fireEvent.click(playButtons[0]);
+ });
+
+ expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
+ expect.stringContaining('localhost:5656'),
+ 'vod',
+ mockEpisode
+ );
+ });
+
+ it('should copy episode link when copy button clicked', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const copyButtons = screen.getAllByTestId('action-icon');
+ fireEvent.click(copyButtons[1]);
+ });
+
+ expect(copyToClipboard).toHaveBeenCalledWith(
+ expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
+ expect.objectContaining({
+ successTitle: 'Link Copied!',
+ successMessage: 'Episode link copied to clipboard',
+ })
+ );
+ });
+
+ it('should expand episode details when row clicked', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const rows = screen.getAllByTestId('table-tr');
+ fireEvent.click(rows[1]);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/8\.0/)).toBeInTheDocument();
+ });
+ });
+
+ it('should collapse episode details when clicked again', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const rows = screen.getAllByTestId('table-tr');
+ fireEvent.click(rows[1]);
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/8\.0/)).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ const rows = screen.getAllByTestId('table-tr');
+ fireEvent.click(rows[1]);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByText(/8\.0/)).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ it('should show trailer button when youtube_trailer exists', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Watch Trailer')).toBeInTheDocument();
+ });
+ });
+
+ describe('Season Tabs', () => {
+ it('should create tabs for each season', async () => {
+ const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [mockEpisode, season2Episode],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
+ expect(screen.getByText(/Season 2/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should sort seasons in ascending order', async () => {
+ const season3Episode = { ...mockEpisode, id: 3, season_number: 3 };
+ const season2Episode = { ...mockEpisode, id: 2, season_number: 2 };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [season3Episode, mockEpisode, season2Episode],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const tabs = screen.getAllByTestId('tabs-tab');
+ expect(tabs[0]).toHaveTextContent('Season 1');
+ expect(tabs[1]).toHaveTextContent('Season 2');
+ expect(tabs[2]).toHaveTextContent('Season 3');
+ });
+ });
+
+ it('should activate first season tab by default', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const tabs = screen.getByTestId('tabs');
+ expect(tabs).toHaveAttribute('data-value', 'season-1');
+ });
+ });
+ });
+
+ describe('Quality Info Extraction', () => {
+ it('should extract quality from quality_info field', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
+ });
+ });
+
+ it('should extract quality from custom_properties.detailed_info.video', async () => {
+ const customProviders = [
+ {
+ ...mockProviders[0],
+ quality_info: null,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 1920, height: 1080 },
+ },
+ },
+ },
+ mockProviders[1],
+ ];
+ mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
+
+ render(
+
+ );
+
+
+ await waitFor(() => {
+ expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
+ });
+ });
+
+ it('should extract quality from stream_name as fallback', async () => {
+ const customProviders = [
+ {
+ ...mockProviders[0],
+ quality_info: null,
+ stream_name: 'Test Series 720p',
+ },
+ mockProviders[1],
+ ];
+ mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Provider 1 - 720p/)).toBeInTheDocument();
+ });
+ });
+
+ it('should detect 4K quality', async () => {
+ const customProviders = [
+ {
+ ...mockProviders[0],
+ quality_info: null,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 3840, height: 2160 },
+ },
+ },
+ },
+ mockProviders[1],
+ ];
+ mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Provider 1 - 4K/)).toBeInTheDocument();
+ });
+ });
+
+ it('should show resolution when standard quality not detected', async () => {
+ const customProviders = [
+ {
+ ...mockProviders[0],
+ quality_info: null,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 720, height: 480 },
+ },
+ },
+ },
+ mockProviders[1],
+ ];
+ mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/720x480/)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle series with no episodes', async () => {
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('table')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should handle fetch errors gracefully', async () => {
+ mockVODStore.fetchSeriesInfo.mockRejectedValue(new Error('Fetch failed'));
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
+ });
+ });
+
+ it('should handle episodes with null season_number', async () => {
+ const episodeNoSeason = { ...mockEpisode, season_number: null };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [episodeNoSeason],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle empty provider list', async () => {
+ mockVODStore.fetchSeriesProviders.mockResolvedValue([]);
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Account')).toBeInTheDocument()
+ });
+ });
+ });
+
+ describe('Modal Close', () => {
+ it('should call onClose when close button clicked', async () => {
+ const onClose = vi.fn();
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const closeButton = screen.getByTestId('modal-close');
+ fireEvent.click(closeButton);
+ });
+
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ describe('Helper Functions', () => {
+ it('should format duration with hours and minutes', () => {
+ const duration = 7265; // 2h 1m 5s
+ // This tests the formatDuration function indirectly through episode display
+ const episode = {
+ ...mockEpisode,
+ info: { ...mockEpisode.info, duration },
+ };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [episode],
+ });
+
+ render(
+
+ );
+
+ waitFor(() => {
+ expect(screen.getByText(/2h 1m/)).toBeInTheDocument();
+ });
+ });
+
+ it('should format duration with minutes and seconds when under an hour', () => {
+ const duration = 125; // 2m 5s
+ const episode = {
+ ...mockEpisode,
+ info: { ...mockEpisode.info, duration },
+ };
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [episode],
+ });
+
+ render(
+
+ );
+
+ waitFor(() => {
+ expect(screen.getByText(/2m 5s/)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx
new file mode 100644
index 00000000..341d0c32
--- /dev/null
+++ b/frontend/src/components/__tests__/Sidebar.test.jsx
@@ -0,0 +1,455 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { BrowserRouter } from 'react-router-dom';
+import Sidebar from '../Sidebar';
+import useChannelsStore from '../../store/channels';
+import useSettingsStore from '../../store/settings';
+import useAuthStore from '../../store/auth';
+import { copyToClipboard } from '../../utils';
+import { USER_LEVELS } from '../../constants';
+
+// Mock stores
+vi.mock('../../store/channels');
+vi.mock('../../store/settings');
+vi.mock('../../store/auth');
+vi.mock('../../utils', () => ({
+ copyToClipboard: vi.fn(),
+}));
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ ListOrdered: ({ onClick }) => ,
+ Play: ({ onClick }) => ,
+ Database: ({ onClick }) => ,
+ LayoutGrid: ({ onClick }) => ,
+ Settings: ({ onClick }) => ,
+ Copy: ({ onClick }) => ,
+ ChartLine: ({ onClick }) => ,
+ Video: ({ onClick }) => ,
+ PlugZap: ({ onClick }) => ,
+ LogOut: ({ onClick }) => ,
+ User: ({ onClick }) => ,
+ FileImage: ({ onClick }) => ,
+}));
+
+// Mock UserForm component
+vi.mock('../forms/User', () => ({
+ default: ({ isOpen, onClose, user }) => (
+ isOpen ? (
+
+ User Form for {user?.username}
+
+
+ ) : null
+ ),
+}));
+
+vi.mock('@mantine/core', async () => {
+ return {
+ Avatar: ({ children }) => {children}
,
+ Group: ({ children, onClick, ...props }) => (
+ {children}
+ ),
+ Stack: ({ children }) => {children}
,
+ Box: ({ children }) => {children}
,
+ Text: ({ children }) => {children}
,
+ UnstyledButton: ({ children, onClick, component, to, className }) => {
+ const Component = component || 'button';
+ return (
+
+ {children}
+
+ );
+ },
+ TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
+
+ {label && }
+ {leftSection}
+
+ {rightSection}
+
+ ),
+ ActionIcon: ({ children, onClick, ...props }) => (
+
+ ),
+ AppShellNavbar: ({ children, style, width, ...props }) => (
+
+ ),
+ };
+});
+
+const mockChannels = {
+ 'channel-1': { id: 'channel-1', name: 'Channel 1' },
+ 'channel-2': { id: 'channel-2', name: 'Channel 2' },
+ 'channel-3': { id: 'channel-3', name: 'Channel 3' },
+};
+
+const mockEnvironment = {
+ public_ip: '192.168.1.1',
+ country_code: 'US',
+ country_name: 'United States',
+};
+
+const mockVersion = {
+ version: '1.2.3',
+ timestamp: '20240115',
+};
+
+const mockAdminUser = {
+ id: 1,
+ username: 'admin',
+ first_name: 'Admin',
+ user_level: USER_LEVELS.ADMIN,
+};
+
+const mockRegularUser = {
+ id: 2,
+ username: 'user',
+ first_name: 'John',
+ user_level: USER_LEVELS.USER,
+};
+
+const renderSidebar = (props = {}) => {
+ const defaultProps = {
+ collapsed: false,
+ toggleDrawer: vi.fn(),
+ drawerWidth: 250,
+ miniDrawerWidth: 80,
+ };
+
+ return render(
+
+
+
+ );
+};
+
+describe('Sidebar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ useChannelsStore.mockReturnValue(mockChannels);
+ useSettingsStore.mockImplementation((selector) => {
+ const state = {
+ environment: mockEnvironment,
+ version: mockVersion,
+ };
+ return selector(state);
+ });
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: true,
+ user: mockAdminUser,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+ });
+
+ describe('Brand Section', () => {
+ it('should render logo and brand name when expanded', () => {
+ const { container } = renderSidebar();
+
+ expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
+ const logo = container.querySelectorAll('img[src="/src/images/logo.png"]');
+ expect(logo).toHaveLength(1);
+ });
+
+ it('should hide brand name when collapsed', () => {
+ renderSidebar({ collapsed: true });
+ expect(screen.queryByText('Dispatcharr')).not.toBeInTheDocument();
+ });
+
+ it('should toggle drawer when brand is clicked', () => {
+ const toggleDrawer = vi.fn();
+ renderSidebar({ toggleDrawer });
+
+ const brand = screen.getByText('Dispatcharr').closest('div');
+ fireEvent.click(brand);
+
+ expect(toggleDrawer).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Navigation Links - Admin User', () => {
+ it('should render all admin navigation items', () => {
+ renderSidebar();
+
+ expect(screen.getByText('Channels')).toBeInTheDocument();
+ expect(screen.getByText('VODs')).toBeInTheDocument();
+ expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument();
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ expect(screen.getByText('DVR')).toBeInTheDocument();
+ expect(screen.getByText('Stats')).toBeInTheDocument();
+ expect(screen.getByText('Plugins')).toBeInTheDocument();
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Logo Manager')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+
+ it('should display channel count badge', () => {
+ renderSidebar();
+ expect(screen.getByText('(3)')).toBeInTheDocument();
+ });
+
+ it('should hide labels and badges when collapsed', () => {
+ renderSidebar({ collapsed: true });
+
+ expect(screen.queryByText('Channels')).not.toBeInTheDocument();
+ expect(screen.queryByText('(3)')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Navigation Links - Regular User', () => {
+ beforeEach(() => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: true,
+ user: mockRegularUser,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+ });
+
+ it('should render limited navigation items for regular user', () => {
+ renderSidebar();
+
+ expect(screen.getByText('Channels')).toBeInTheDocument();
+ expect(screen.getByText('TV Guide')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+
+ expect(screen.queryByText('VODs')).not.toBeInTheDocument();
+ expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument();
+ expect(screen.queryByText('DVR')).not.toBeInTheDocument();
+ expect(screen.queryByText('Stats')).not.toBeInTheDocument();
+ expect(screen.queryByText('Plugins')).not.toBeInTheDocument();
+ expect(screen.queryByText('Users')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Profile Section - Authenticated', () => {
+ it('should render public IP with country flag', () => {
+ renderSidebar();
+
+ const ipInput = screen.getByDisplayValue('192.168.1.1');
+ expect(ipInput).toBeInTheDocument();
+
+ const flag = screen.getByAltText('United States');
+ expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
+ });
+
+ it('should copy public IP to clipboard when copy button is clicked', async () => {
+ copyToClipboard.mockResolvedValue();
+ renderSidebar();
+
+ const copyButton = screen.getByTestId('copy-icon').closest('button');
+ fireEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(copyToClipboard).toHaveBeenCalledWith('192.168.1.1', {
+ successTitle: 'Success',
+ successMessage: 'Public IP copied to clipboard',
+ });
+ });
+ });
+
+ it('should render user avatar and name', () => {
+ renderSidebar();
+
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+ });
+
+ it('should fallback to username if first_name is not set', () => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: true,
+ user: { ...mockAdminUser, first_name: null },
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+ expect(screen.getByText('admin')).toBeInTheDocument();
+ });
+
+ it('should open user form when username is clicked', () => {
+ renderSidebar();
+
+ const nameButton = screen.getByText('Admin');
+ fireEvent.click(nameButton);
+
+ expect(screen.getByTestId('user-form')).toBeInTheDocument();
+ expect(screen.getByText('User Form for admin')).toBeInTheDocument();
+ });
+
+ it('should close user form when close button is clicked', () => {
+ renderSidebar();
+
+ const nameButton = screen.getByText('Admin');
+ fireEvent.click(nameButton);
+
+ expect(screen.getByTestId('user-form')).toBeInTheDocument();
+
+ const closeButton = screen.getByText('Close');
+ fireEvent.click(closeButton);
+
+ expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
+ });
+
+ it('should logout when logout button is clicked', () => {
+ const mockLogout = vi.fn();
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: true,
+ user: mockAdminUser,
+ logout: mockLogout,
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+
+ const logoutIcon = screen.getByTestId('logout-icon');
+ fireEvent.click(logoutIcon);
+
+ expect(mockLogout).toHaveBeenCalledTimes(1);
+ });
+
+ it('should hide profile details when collapsed', () => {
+ renderSidebar({ collapsed: true });
+
+ expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
+ expect(screen.queryByText('Admin')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Profile Section - Not Authenticated', () => {
+ beforeEach(() => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: false,
+ user: null,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+ });
+
+ it('should not render profile section when not authenticated', () => {
+ renderSidebar();
+
+ expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
+ expect(screen.queryByText('Admin')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Version Display', () => {
+ it('should render version when expanded', () => {
+ renderSidebar();
+ expect(screen.getByText('v1.2.3-20240115')).toBeInTheDocument();
+ });
+
+ it('should render version without timestamp if not available', () => {
+ useSettingsStore.mockImplementation((selector) => {
+ const state = {
+ environment: mockEnvironment,
+ version: { version: '1.2.3' },
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+ expect(screen.getByText('v1.2.3')).toBeInTheDocument();
+ });
+
+ it('should render default version if not loaded', () => {
+ useSettingsStore.mockImplementation((selector) => {
+ const state = {
+ environment: mockEnvironment,
+ version: null,
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+ expect(screen.getByText('v0.0.0')).toBeInTheDocument();
+ });
+
+ it('should hide version when collapsed', () => {
+ renderSidebar({ collapsed: true });
+ expect(screen.queryByText(/v1.2.3-20240115/)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Collapsed State', () => {
+ it('should apply collapsed class to navigation links', () => {
+ renderSidebar({ collapsed: true });
+
+ const links = screen.getAllByRole('link');
+ links.forEach(link => {
+ expect(link).toHaveClass('navlink-collapsed');
+ });
+ });
+
+ it('should adjust width based on collapsed state', () => {
+ const { rerender } = renderSidebar({ collapsed: false, drawerWidth: 250 });
+ const navbar = screen.getByRole('navigation');
+
+ expect(navbar).toHaveStyle({ width: '250px' });
+
+ rerender(
+
+
+
+ );
+
+ expect(navbar).toHaveStyle({ width: '80px' });
+ });
+ });
+
+ describe('Environment Edge Cases', () => {
+ it('should handle missing country code gracefully', () => {
+ useSettingsStore.mockImplementation((selector) => {
+ const state = {
+ environment: { public_ip: '192.168.1.1' },
+ version: mockVersion,
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+ expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
+ expect(screen.queryByRole('img', { name: /flag/i })).not.toBeInTheDocument();
+ });
+
+ it('should use country code as alt text if country name is missing', () => {
+ useSettingsStore.mockImplementation((selector) => {
+ const state = {
+ environment: {
+ public_ip: '192.168.1.1',
+ country_code: 'US',
+ },
+ version: mockVersion,
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+ const flag = screen.getByAltText('US');
+ expect(flag).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/SystemEvents.test.jsx b/frontend/src/components/__tests__/SystemEvents.test.jsx
new file mode 100644
index 00000000..238d3fce
--- /dev/null
+++ b/frontend/src/components/__tests__/SystemEvents.test.jsx
@@ -0,0 +1,251 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import SystemEvents from '../SystemEvents';
+import API from '../../api';
+import useLocalStorage from "../../hooks/useLocalStorage";
+
+// Mock the API module
+vi.mock('../../api', () => ({
+ default: {
+ getSystemEvents: vi.fn(),
+ },
+}));
+
+// Mock the useLocalStorage hook
+vi.mock('../../hooks/useLocalStorage', () => ({
+ default: vi.fn((key, defaultValue) => {
+ const mockSetters = {
+ 'events-refresh-interval': vi.fn(),
+ 'events-limit': vi.fn(),
+ 'date-format': vi.fn(),
+ };
+ return [defaultValue, mockSetters[key] || vi.fn()];
+ }),
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ ActionIcon: ({ children, onClick }) => (
+
+ ),
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick }) => (
+
+ ),
+ Card: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ NumberInput: ({ value, onChange, label }) => (
+ onChange(Number(e.target.value))}
+ />
+ ),
+ Pagination: ({ page, onChange, total }) => (
+
+ {Array.from({ length: Math.ceil(total / 100) }, (_, i) => (
+
+ ))}
+
+ ),
+ Select: ({ value, onChange, data }) => (
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children}
,
+ Title: ({ children }) => {children}
,
+ };
+});
+
+// Mock the dateTimeUtils
+vi.mock('../../utils/dateTimeUtils.js', () => ({
+ format: vi.fn((timestamp, format) => '01/15 10:30:45'),
+}));
+
+const mockEventsResponse = {
+ events: [
+ {
+ id: 1,
+ event_type: 'channel_start',
+ event_type_display: 'Channel Started',
+ channel_name: 'Test Channel',
+ timestamp: '2024-01-15T10:30:45Z',
+ details: { bitrate: '5000kbps' },
+ },
+ {
+ id: 2,
+ event_type: 'login_success',
+ event_type_display: 'Login Successful',
+ timestamp: '2024-01-15T10:25:30Z',
+ details: {},
+ },
+ ],
+ total: 2,
+};
+
+describe('SystemEvents', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ API.getSystemEvents.mockResolvedValue(mockEventsResponse);
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ });
+
+ it('should render component with title', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('System Events')).toBeInTheDocument();
+ });
+ });
+
+ it('should fetch and display events on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(API.getSystemEvents).toHaveBeenCalledWith(100, 0);
+ });
+ });
+
+ it('should expand and show events when chevron is clicked', async () => {
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Channel Started')).toBeInTheDocument();
+ expect(screen.getByText('Login Successful')).toBeInTheDocument();
+ });
+ });
+
+ it('should display "No events recorded yet" when events array is empty', async () => {
+ API.getSystemEvents.mockResolvedValue({ events: [], total: 0 });
+
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('No events recorded yet')).toBeInTheDocument();
+ });
+ });
+
+ it('should call fetchEvents when refresh button is clicked', async () => {
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Refresh')).toBeInTheDocument();
+ });
+
+ const refreshButton = screen.getByText('Refresh');
+ fireEvent.click(refreshButton);
+
+ await waitFor(() => {
+ expect(API.getSystemEvents).toHaveBeenCalledTimes(3)
+ });
+ });
+
+ it('should update events limit when changed', async () => {
+ const mockSetEventsLimit = vi.fn();
+
+ // Update the mock to return the setter for this test
+ useLocalStorage.mockImplementation((key, defaultValue) => {
+ if (key === 'events-limit') {
+ return [100, mockSetEventsLimit];
+ }
+ return [defaultValue, vi.fn()];
+ });
+
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ const input = screen.getByLabelText('Events Per Page');
+ fireEvent.change(input, { target: { value: '50' } });
+ });
+
+ expect(mockSetEventsLimit).toHaveBeenCalled();
+ });
+
+ it('should show pagination when total events exceed limit', async () => {
+ API.getSystemEvents.mockResolvedValue({
+ events: mockEventsResponse.events,
+ total: 150,
+ });
+
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Showing 1-100 of 150/)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle API errors gracefully', async () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ API.getSystemEvents.mockRejectedValue(new Error('API Error'));
+
+ render();
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error fetching system events:',
+ expect.any(Error)
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should display event details correctly', async () => {
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Channel')).toBeInTheDocument();
+ expect(screen.getByText('bitrate: 5000kbps')).toBeInTheDocument();
+ });
+ });
+
+ it('should change page when pagination is clicked', async () => {
+ API.getSystemEvents.mockResolvedValue({
+ events: mockEventsResponse.events,
+ total: 250,
+ });
+
+ render();
+
+ const expandButton = screen.getByRole('button', { name: '' });
+ fireEvent.click(expandButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Showing 1-100 of 250/)).toBeInTheDocument();
+ });
+
+ // Note: Pagination interaction would require more specific selectors
+ // based on Mantine's Pagination component implementation
+ });
+});
diff --git a/frontend/src/components/__tests__/VODModal.test.jsx b/frontend/src/components/__tests__/VODModal.test.jsx
new file mode 100644
index 00000000..7dc6ed40
--- /dev/null
+++ b/frontend/src/components/__tests__/VODModal.test.jsx
@@ -0,0 +1,439 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import VODModal from '../VODModal';
+import useVODStore from '../../store/useVODStore';
+import useVideoStore from '../../store/useVideoStore';
+import useSettingsStore from '../../store/settings';
+
+// Mock stores
+vi.mock('../../store/useVODStore');
+vi.mock('../../store/useVideoStore');
+vi.mock('../../store/settings');
+
+// Mock utils
+vi.mock('../../utils', () => ({
+ copyToClipboard: vi.fn(() => Promise.resolve()),
+}));
+
+vi.mock('../../utils/components/SeriesModalUtils.js', () => ({
+ formatStreamLabel: vi.fn((provider) => `${provider.m3u_account.name} - Stream ${provider.stream_id}`),
+ imdbUrl: vi.fn((id) => `https://www.imdb.com/title/${id}`),
+ tmdbUrl: vi.fn((id, type) => `https://www.themoviedb.org/${type}/${id}`),
+ formatDuration: vi.fn((secs) => `${Math.floor(secs / 60)} min`),
+ getYouTubeEmbedUrl: vi.fn((url) => `https://www.youtube.com/embed/${url}`),
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Box: ({ children, ...props }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Image: ({ src, alt }) =>
,
+ Text: ({ children, ...props }) => {children},
+ Title: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, leftSection }) => (
+
+ ),
+ Badge: ({ children, component, href, ...props }) =>
+ component === 'a' ? (
+
+ {children}
+
+ ) : (
+ {children}
+ ),
+ Select: ({ data, value, onChange, placeholder, disabled }) => (
+
+ ),
+ Loader: () => Loading...
,
+ };
+});
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ Play: () => Play Icon,
+ Copy: () => Copy Icon,
+}));
+
+describe('VODModal', () => {
+ const mockShowVideo = vi.fn();
+ const mockFetchMovieDetailsFromProvider = vi.fn();
+ const mockFetchMovieProviders = vi.fn();
+ const mockOnClose = vi.fn();
+
+ const mockVOD = {
+ id: 1,
+ uuid: 'test-uuid',
+ name: 'Test Movie',
+ o_name: 'Original Test Movie',
+ year: 2023,
+ duration_secs: 7200,
+ rating: '8.5',
+ age: 'PG-13',
+ imdb_id: 'tt1234567',
+ tmdb_id: '12345',
+ release_date: '2023-01-15',
+ genre: 'Action, Sci-Fi',
+ director: 'Test Director',
+ actors: 'Actor 1, Actor 2',
+ country: 'USA',
+ description: 'A test movie description',
+ youtube_trailer: 'test-trailer-id',
+ movie_image: 'https://example.com/poster.jpg',
+ backdrop_path: ['https://example.com/backdrop.jpg'],
+ bitrate: 5000,
+ m3u_account: { name: 'Test Account', id: 1 },
+ };
+
+ const mockProvider = {
+ id: 1,
+ stream_id: 'stream-123',
+ m3u_account: { name: 'Test Provider', id: 1 },
+ bitrate: 6000,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ useVideoStore.mockImplementation((selector) => {
+ const state = { showVideo: mockShowVideo };
+ return selector ? selector(state) : state;
+ });
+
+ useVODStore.mockImplementation((selector) => {
+ const state = {
+ fetchMovieDetailsFromProvider: mockFetchMovieDetailsFromProvider,
+ fetchMovieProviders: mockFetchMovieProviders,
+ };
+ return selector ? selector(state) : state;
+ });
+
+ useSettingsStore.mockImplementation((selector) => {
+ const state = { environment: { env_mode: 'prod' } };
+ return selector ? selector(state) : state;
+ });
+
+ mockFetchMovieDetailsFromProvider.mockResolvedValue(mockVOD);
+ mockFetchMovieProviders.mockResolvedValue([mockProvider]);
+ });
+
+ it('should not render when vod is null', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should render modal when opened with vod', () => {
+ render();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
+ });
+
+ it('should not render when closed', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should display movie details correctly', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Original: Original Test Movie')).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('2023')).toBeInTheDocument();
+ expect(screen.getByText('8.5')).toBeInTheDocument();
+ expect(screen.getByText('PG-13')).toBeInTheDocument();
+ expect(screen.getByText('Action, Sci-Fi')).toBeInTheDocument();
+ expect(screen.getByText(/Test Director/)).toBeInTheDocument();
+ expect(screen.getByText(/Actor 1, Actor 2/)).toBeInTheDocument();
+ expect(screen.getByText(/A test movie description/)).toBeInTheDocument();
+ });
+
+ it('should fetch movie details on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id);
+ });
+ });
+
+ it('should fetch movie providers on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchMovieProviders).toHaveBeenCalledWith(mockVOD.id);
+ });
+ });
+
+ it('should show loading state while fetching details', () => {
+ mockFetchMovieDetailsFromProvider.mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ render();
+
+ expect(screen.getByText('Loading additional details...')).toBeInTheDocument();
+ });
+
+ it('should handle play button click', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchMovieProviders).toHaveBeenCalled();
+ });
+
+ const playButton = screen.getByText('Play Movie');
+ fireEvent.click(playButton);
+
+ expect(mockShowVideo).toHaveBeenCalled();
+ });
+
+ it('should disable play button when multiple providers and none selected', async () => {
+ mockFetchMovieProviders.mockResolvedValue([mockProvider, { ...mockProvider, id: 2 }]);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Play Movie')).toBeInTheDocument();
+ });
+
+ // Note: Testing for disabled state would require checking the button's disabled attribute
+ });
+
+ it('should handle provider selection', async () => {
+ const providers = [
+ mockProvider,
+ { ...mockProvider, id: 2, stream_id: 'stream-456' },
+ ];
+ mockFetchMovieProviders.mockResolvedValue(providers);
+
+ render();
+
+ await waitFor(() => {
+ const select = screen.getByTestId('provider-select');
+ expect(select).toBeInTheDocument();
+ });
+
+ const select = screen.getByTestId('provider-select');
+ fireEvent.change(select, { target: { value: '2' } });
+
+ await waitFor(() => {
+ const select = screen.getByTestId('provider-select');
+ expect(select).toHaveValue('2');
+ });
+ });
+
+ it('should display single provider as badge', async () => {
+ mockFetchMovieProviders.mockResolvedValue([mockProvider]);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Provider')).toBeInTheDocument();
+ });
+ });
+
+ it('should handle fetch details error gracefully', async () => {
+ mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed'));
+
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalled();
+ });
+
+ // Should still display basic VOD info
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
+ });
+
+ it('should handle fetch providers error gracefully', async () => {
+ mockFetchMovieProviders.mockRejectedValue(new Error('Fetch failed'));
+
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchMovieProviders).toHaveBeenCalled();
+ });
+
+ // Should still render without providers
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('should display technical details when available', async () => {
+ const vodWithTech = {
+ ...mockVOD,
+ bitrate: 5000,
+ video: {
+ codec_name: 'h264',
+ width: 1920,
+ height: 1080,
+ },
+ audio: {
+ codec_name: 'aac',
+ channels: 2,
+ },
+ };
+
+ mockFetchMovieDetailsFromProvider.mockResolvedValue(vodWithTech);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Technical Details:/)).toBeInTheDocument();
+ });
+ });
+
+ it('should render IMDb and TMDb badges with correct links', () => {
+ render();
+
+ const imdbLink = screen.getByText('IMDb');
+ const tmdbLink = screen.getByText('TMDb');
+
+ expect(imdbLink).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
+ expect(tmdbLink).toHaveAttribute('href', 'https://www.themoviedb.org/movie/12345');
+ });
+
+ describe('Copy Link Functionality', () => {
+ it('should copy movie link when copy button clicked', async () => {
+ const { copyToClipboard } = await import('../../utils');
+
+ render();
+
+ await waitFor(() => {
+ const copyButton = screen.getByText('Copy Link');
+ fireEvent.click(copyButton);
+ });
+
+ expect(copyToClipboard).toHaveBeenCalledWith(
+ expect.stringContaining('/proxy/vod/movie/test-uuid'),
+ expect.objectContaining({
+ successTitle: expect.any(String),
+ successMessage: expect.any(String),
+ })
+ );
+ });
+
+ it('should include provider stream_id in copied URL when provider selected', async () => {
+ const { copyToClipboard } = await import('../../utils');
+
+ mockFetchMovieProviders.mockResolvedValue([
+ { ...mockProvider, id: 1 },
+ { ...mockProvider, id: 2, stream_id: 'stream-456' },
+ ]);
+
+ render();
+
+ await waitFor(() => {
+ const select = screen.getByTestId('provider-select');
+ fireEvent.change(select, { target: { value: '2' } });
+ });
+
+ await waitFor(() => {
+ const copyButton = screen.getByText('Copy Link')
+ fireEvent.click(copyButton);
+ });
+
+ expect(copyToClipboard).toHaveBeenCalledWith(
+ expect.stringContaining('stream_id=stream-456'),
+ expect.any(Object)
+ );
+ });
+ });
+
+ describe('URL Generation', () => {
+ it('should use dev mode URL in development', async () => {
+ useSettingsStore.mockImplementation((selector) => {
+ const state = { environment: { env_mode: 'dev' } };
+ return selector ? selector(state) : state;
+ });
+
+ render();
+
+ await waitFor(() => {
+ const playButton = screen.getByText('Play Movie');
+ fireEvent.click(playButton);
+ });
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.stringContaining('localhost:5656'),
+ 'vod',
+ expect.any(Object)
+ );
+ });
+
+ it('should use production URL in production', async () => {
+ render();
+
+ await waitFor(() => {
+ const playButton = screen.getByText('Play Movie');
+ fireEvent.click(playButton);
+ });
+
+ expect(mockShowVideo).toHaveBeenCalledWith(
+ expect.not.stringContaining('localhost:5656'),
+ 'vod',
+ expect.any(Object)
+ );
+ });
+ });
+
+ describe('Modal Interaction', () => {
+ it('should call onClose when close button clicked', async () => {
+ render();
+
+ await waitFor(() => {
+ const closeButton = screen.getByTestId('modal-close');
+ fireEvent.click(closeButton);
+ });
+
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle VOD with no image', async () => {
+ const vodNoImage = { ...mockVOD, movie_image: null };
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+ });
+
+ it('should handle VOD with missing optional fields', async () => {
+ const minimalVOD = {
+ id: 1,
+ uuid: 'test-uuid',
+ name: 'Test Movie',
+ m3u_account: { name: 'Test Account', id: 1 },
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx b/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx
new file mode 100644
index 00000000..da549949
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx
@@ -0,0 +1,109 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { YouTubeTrailerModal } from '../YouTubeTrailerModal';
+
+// Mock Mantine components
+vi.mock('@mantine/core', () => ({
+ Modal: ({ opened, onClose, title, children, size, centered }) => {
+ if (!opened) return null;
+ return (
+
+ );
+ },
+ Box: ({ children, ...props }) => (
+ {children}
+ ),
+}));
+
+describe('YouTubeTrailerModal', () => {
+ const mockTrailerUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ';
+ const mockOnClose = vi.fn();
+
+ it('should not render when opened is false', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should render modal when opened is true', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('should display correct modal title', () => {
+ render(
+
+ );
+
+ const modal = screen.getByTestId('modal');
+ expect(modal).toHaveAttribute('data-title', 'Trailer');
+ });
+
+ it('should render iframe with correct trailerUrl', () => {
+ render(
+
+ );
+
+ const iframe = screen.getByTitle('YouTube Trailer');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe).toHaveAttribute('src', mockTrailerUrl);
+ });
+
+ it('should not render iframe when trailerUrl is null', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTitle('YouTube Trailer')).not.toBeInTheDocument();
+ });
+
+ it('should call onClose when close button clicked', () => {
+ const mockClose = vi.fn();
+
+ render(
+
+ );
+
+ const closeButton = screen.getByTestId('modal-close');
+ closeButton.click();
+
+ expect(mockClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js b/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js
new file mode 100644
index 00000000..8524a310
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js
@@ -0,0 +1,235 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ getLivePlayerErrorMessage,
+ getVODPlayerErrorMessage,
+ getClientCoordinates,
+ calculateNewDimensions,
+ applyConstraints,
+} from '../FloatingVideoUtils';
+
+describe('FloatingVideoUtils', () => {
+ describe('getLivePlayerErrorMessage', () => {
+ it('should return formatted error for non-MediaError types', () => {
+ expect(getLivePlayerErrorMessage('NetworkError', 'Connection failed')).toBe(
+ 'Error: NetworkError - Connection failed'
+ );
+ });
+
+ it('should return error type only when no detail provided', () => {
+ expect(getLivePlayerErrorMessage('NetworkError')).toBe('Error: NetworkError');
+ });
+
+ it('should return audio codec message for audio-related errors', () => {
+ const result = getLivePlayerErrorMessage('MediaError', 'audio codec not supported');
+ expect(result).toBe(
+ 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'
+ );
+ });
+
+ it('should return audio codec message for AC3 errors', () => {
+ const result = getLivePlayerErrorMessage('MediaError', 'AC3 codec issue');
+ expect(result).toContain('Audio codec not supported');
+ });
+
+ it('should return video codec message for video-related errors', () => {
+ const result = getLivePlayerErrorMessage('MediaError', 'video codec h264 failed');
+ expect(result).toBe(
+ 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'
+ );
+ });
+
+ it('should return MSE message for MSE-related errors', () => {
+ const result = getLivePlayerErrorMessage('MediaError', 'MSE not supported');
+ expect(result).toBe(
+ "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."
+ );
+ });
+
+ it('should return generic codec message for other MediaError cases', () => {
+ const result = getLivePlayerErrorMessage('MediaError', 'unknown codec issue');
+ expect(result).toBe(
+ 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
+ );
+ });
+
+ it('should handle null errorDetail for MediaError', () => {
+ const result = getLivePlayerErrorMessage('MediaError', null);
+ expect(result).toBe(
+ 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
+ );
+ });
+ });
+
+ describe('getVODPlayerErrorMessage', () => {
+ it('should return default message when error is null', () => {
+ expect(getVODPlayerErrorMessage(null)).toBe('Video playback error');
+ });
+
+ it('should return aborted message for MEDIA_ERR_ABORTED', () => {
+ const error = { code: 1, MEDIA_ERR_ABORTED: 1 };
+ expect(getVODPlayerErrorMessage(error)).toBe('Video playback was aborted');
+ });
+
+ it('should return network message for MEDIA_ERR_NETWORK', () => {
+ const error = { code: 2, MEDIA_ERR_NETWORK: 2 };
+ expect(getVODPlayerErrorMessage(error)).toBe('Network error while loading video');
+ });
+
+ it('should return codec message for MEDIA_ERR_DECODE', () => {
+ const error = { code: 3, MEDIA_ERR_DECODE: 3 };
+ expect(getVODPlayerErrorMessage(error)).toBe('Video codec not supported by your browser');
+ });
+
+ it('should return format message for MEDIA_ERR_SRC_NOT_SUPPORTED', () => {
+ const error = { code: 4, MEDIA_ERR_SRC_NOT_SUPPORTED: 4 };
+ expect(getVODPlayerErrorMessage(error)).toBe('Video format not supported by your browser');
+ });
+
+ it('should return error message for unknown error codes', () => {
+ const error = { code: 99, message: 'Custom error message' };
+ expect(getVODPlayerErrorMessage(error)).toBe('Custom error message');
+ });
+
+ it('should return default message for unknown error without message', () => {
+ const error = { code: 99 };
+ expect(getVODPlayerErrorMessage(error)).toBe('Unknown video error');
+ });
+ });
+
+ describe('getClientCoordinates', () => {
+ it('should extract coordinates from mouse event', () => {
+ const event = { clientX: 100, clientY: 200 };
+ expect(getClientCoordinates(event)).toEqual({ clientX: 100, clientY: 200 });
+ });
+
+ it('should extract coordinates from touch event', () => {
+ const event = { touches: [{ clientX: 150, clientY: 250 }] };
+ expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
+ });
+
+ it('should prioritize touch coordinates over mouse coordinates', () => {
+ const event = {
+ touches: [{ clientX: 150, clientY: 250 }],
+ clientX: 100,
+ clientY: 200,
+ };
+ expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
+ });
+
+ it('should handle undefined coordinates', () => {
+ const event = {};
+ expect(getClientCoordinates(event)).toEqual({ clientX: undefined, clientY: undefined });
+ });
+ });
+
+ describe('calculateNewDimensions', () => {
+ const ratio = 16 / 9;
+
+ it('should calculate dimensions based on horizontal drag', () => {
+ const handle = { xDir: 1, yDir: 0, isLeft: false, isTop: false };
+ const result = calculateNewDimensions(100, 10, 400, 225, handle, ratio);
+
+ expect(result.width).toBe(500);
+ expect(result.height).toBeCloseTo(281.25, 1);
+ });
+
+ it('should calculate dimensions based on vertical drag', () => {
+ const handle = { xDir: 0, yDir: 1, isLeft: false, isTop: false };
+ const result = calculateNewDimensions(10, 100, 400, 225, handle, ratio);
+
+ expect(result.height).toBe(325);
+ expect(result.width).toBeCloseTo(577.78, 1);
+ });
+
+ it('should use vertical-driven resize when vertical delta is larger', () => {
+ const handle = { xDir: 1, yDir: 1, isLeft: false, isTop: false };
+ const result = calculateNewDimensions(20, 100, 400, 225, handle, ratio);
+
+ expect(result.height).toBe(325);
+ expect(result.width).toBeCloseTo(577.78, 1);
+ });
+
+ it('should handle negative deltas', () => {
+ const handle = { xDir: -1, yDir: 0, isLeft: true, isTop: false };
+ const result = calculateNewDimensions(-50, 0, 400, 225, handle, ratio);
+
+ expect(result.width).toBe(450);
+ expect(result.height).toBeCloseTo(253.13, 1);
+ });
+ });
+
+ describe('applyConstraints', () => {
+ const ratio = 16 / 9;
+ const minWidth = 200;
+ const minHeight = 112.5;
+ const visibleMargin = 50;
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
+ Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
+ });
+
+ it('should apply minimum width constraint', () => {
+ const handle = { isLeft: false, isTop: false };
+ const result = applyConstraints(100, 50, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
+
+ expect(result.width).toBe(minWidth);
+ expect(result.height).toBeCloseTo(minWidth / ratio, 1);
+ });
+
+ it('should apply minimum height constraint', () => {
+ const handle = { isLeft: false, isTop: false };
+ const result = applyConstraints(300, 100, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
+
+ expect(result.height).toBe(minHeight);
+ expect(result.width).toBeCloseTo(minHeight * ratio, 1);
+ });
+
+ it('should apply maximum width constraint based on viewport', () => {
+ const handle = { isLeft: false, isTop: false };
+ const startPos = { x: 1200, y: 100 };
+ const result = applyConstraints(800, 450, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
+
+ const maxWidth = 1920 - 1200 - 50; // 670
+ expect(result.width).toBe(maxWidth);
+ expect(result.height).toBeCloseTo(maxWidth / ratio, 1);
+ });
+
+ it('should apply maximum height constraint based on viewport', () => {
+ const handle = { isLeft: false, isTop: false };
+ const startPos = { x: 100, y: 700 };
+ const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
+
+ const maxHeight = 1080 - 700 - 50; // 330
+ expect(result.height).toBe(maxHeight);
+ expect(result.width).toBeCloseTo(maxHeight * ratio, 1);
+ });
+
+
+ it('should not apply max width constraint for left handle', () => {
+ const handle = { isLeft: true, isTop: false };
+ const startPos = { x: 1800, y: 100 };
+ const result = applyConstraints(500, 281.25, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
+
+ expect(result.width).toBe(500);
+ expect(result.height).toBeCloseTo(281.25, 1);
+ });
+
+ it('should not apply max height constraint for top handle', () => {
+ const handle = { isLeft: false, isTop: true };
+ const startPos = { x: 100, y: 1000 };
+ const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
+
+ expect(result.width).toBe(500);
+ expect(result.height).toBe(400);
+ });
+
+ it('should handle null startPos', () => {
+ const handle = { isLeft: false, isTop: false };
+ const result = applyConstraints(300, 168.75, ratio, null, handle, minWidth, minHeight, visibleMargin);
+
+ expect(result.width).toBe(300);
+ expect(result.height).toBeCloseTo(168.75, 1);
+ });
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js b/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
new file mode 100644
index 00000000..d986cfcc
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
@@ -0,0 +1,397 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ imdbUrl,
+ tmdbUrl,
+ formatDuration,
+ formatStreamLabel,
+ sortEpisodesList,
+ groupEpisodesBySeason,
+ sortBySeasonNumber,
+ getEpisodeStreamUrl,
+ getYouTubeEmbedUrl,
+ getEpisodeAirdate,
+ getTmdbUrlLink
+} from '../SeriesModalUtils';
+
+describe('SeriesModalUtils', () => {
+ describe('imdbUrl', () => {
+ it('should return IMDb URL with valid ID', () => {
+ expect(imdbUrl('tt1234567')).toBe('https://www.imdb.com/title/tt1234567');
+ });
+
+ it('should return empty string when ID is null', () => {
+ expect(imdbUrl(null)).toBe('');
+ });
+
+ it('should return empty string when ID is undefined', () => {
+ expect(imdbUrl(undefined)).toBe('');
+ });
+ });
+
+ describe('tmdbUrl', () => {
+ it('should return TMDB movie URL by default', () => {
+ expect(tmdbUrl('12345')).toBe('https://www.themoviedb.org/movie/12345');
+ });
+
+ it('should return TMDB TV URL when type is tv', () => {
+ expect(tmdbUrl('12345', 'tv')).toBe('https://www.themoviedb.org/tv/12345');
+ });
+
+ it('should return empty string when ID is null', () => {
+ expect(tmdbUrl(null)).toBe('');
+ });
+ });
+
+ describe('formatDuration', () => {
+ it('should format hours and minutes', () => {
+ expect(formatDuration(7200)).toBe('2h 0m');
+ });
+
+ it('should format hours, minutes and seconds', () => {
+ expect(formatDuration(3665)).toBe('1h 1m');
+ });
+
+ it('should format minutes and seconds when under an hour', () => {
+ expect(formatDuration(125)).toBe('2m 5s');
+ });
+
+ it('should return empty string for 0 seconds', () => {
+ expect(formatDuration(0)).toBe('');
+ });
+
+ it('should return empty string for null', () => {
+ expect(formatDuration(null)).toBe('');
+ });
+ });
+
+ describe('formatStreamLabel', () => {
+ const baseRelation = {
+ m3u_account: { name: 'Provider 1' },
+ stream_id: 100
+ };
+
+ it('should format label with quality from quality_info', () => {
+ const relation = {
+ ...baseRelation,
+ quality_info: { quality: '1080p' }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
+ });
+
+ it('should format label with quality from resolution', () => {
+ const relation = {
+ ...baseRelation,
+ quality_info: { resolution: '4K' }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
+ });
+
+ it('should format label with quality from bitrate', () => {
+ const relation = {
+ ...baseRelation,
+ quality_info: { bitrate: '8000kbps' }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 8000kbps (Stream 100)');
+ });
+
+ it('should detect 4K from video dimensions', () => {
+ const relation = {
+ ...baseRelation,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 3840, height: 2160 }
+ }
+ }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
+ });
+
+ it('should detect 1080p from video dimensions', () => {
+ const relation = {
+ ...baseRelation,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 1920, height: 1080 }
+ }
+ }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
+ });
+
+ it('should detect 720p from video dimensions', () => {
+ const relation = {
+ ...baseRelation,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 1280, height: 720 }
+ }
+ }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
+ });
+
+ it('should show custom dimensions for non-standard resolutions', () => {
+ const relation = {
+ ...baseRelation,
+ custom_properties: {
+ detailed_info: {
+ video: { width: 640, height: 480 }
+ }
+ }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 640x480 (Stream 100)');
+ });
+
+ it('should detect quality from detailed_info name field', () => {
+ const relation = {
+ ...baseRelation,
+ custom_properties: {
+ detailed_info: {
+ name: 'Stream 4K HDR'
+ }
+ }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
+ });
+
+ it('should detect quality from stream_name', () => {
+ const relation = {
+ ...baseRelation,
+ stream_name: 'Channel 1080p FHD'
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
+ });
+
+ it('should detect 2160p as 4K', () => {
+ const relation = {
+ ...baseRelation,
+ stream_name: 'Stream 2160p'
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
+ });
+
+ it('should detect FHD as 1080p', () => {
+ const relation = {
+ ...baseRelation,
+ stream_name: 'Stream FHD'
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
+ });
+
+ it('should detect HD as 720p', () => {
+ const relation = {
+ ...baseRelation,
+ stream_name: 'Stream HD'
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
+ });
+
+ it('should format without stream ID when not provided', () => {
+ const relation = {
+ m3u_account: { name: 'Provider 1' },
+ quality_info: { quality: '1080p' }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p');
+ });
+
+ it('should format without quality when not detected', () => {
+ const relation = {
+ m3u_account: { name: 'Provider 1' },
+ stream_id: 100
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 (Stream 100)');
+ });
+
+ it('should prioritize quality_info over detailed_info', () => {
+ const relation = {
+ ...baseRelation,
+ quality_info: { quality: '4K' },
+ custom_properties: {
+ detailed_info: {
+ video: { width: 1920, height: 1080 }
+ }
+ }
+ };
+ expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
+ });
+ });
+
+ describe('sortEpisodesList', () => {
+ it('should sort episodes by season then episode number', () => {
+ const episodes = [
+ { season_number: 2, episode_number: 1 },
+ { season_number: 1, episode_number: 2 },
+ { season_number: 1, episode_number: 1 }
+ ];
+ const sorted = sortEpisodesList(episodes);
+ expect(sorted).toEqual([
+ { season_number: 1, episode_number: 1 },
+ { season_number: 1, episode_number: 2 },
+ { season_number: 2, episode_number: 1 }
+ ]);
+ });
+
+ it('should handle missing season numbers as 0', () => {
+ const episodes = [
+ { season_number: 1, episode_number: 1 },
+ { episode_number: 1 }
+ ];
+ const sorted = sortEpisodesList(episodes);
+ expect(sorted[0].season_number).toBeUndefined();
+ });
+ });
+
+ describe('groupEpisodesBySeason', () => {
+ it('should group episodes by season number', () => {
+ const episodes = [
+ { season_number: 1, episode_number: 1 },
+ { season_number: 1, episode_number: 2 },
+ { season_number: 2, episode_number: 1 }
+ ];
+ const grouped = groupEpisodesBySeason(episodes);
+ expect(grouped[1]).toHaveLength(2);
+ expect(grouped[2]).toHaveLength(1);
+ });
+
+ it('should default missing season numbers to 1', () => {
+ const episodes = [{ episode_number: 1 }];
+ const grouped = groupEpisodesBySeason(episodes);
+ expect(grouped[1]).toHaveLength(1);
+ });
+ });
+
+ describe('sortBySeasonNumber', () => {
+ it('should return season numbers in ascending order', () => {
+ const episodesBySeason = {
+ 3: [],
+ 1: [],
+ 2: []
+ };
+ const sorted = sortBySeasonNumber(episodesBySeason);
+ expect(sorted).toEqual([1, 2, 3]);
+ });
+ });
+
+ describe('getEpisodeStreamUrl', () => {
+ beforeEach(() => {
+ delete window.location;
+ window.location = {
+ protocol: 'https:',
+ hostname: 'example.com',
+ origin: 'https://example.com'
+ };
+ });
+
+ it('should generate stream URL without provider in production', () => {
+ const episode = { uuid: 'episode-123' };
+ const url = getEpisodeStreamUrl(episode, null, 'prod');
+ expect(url).toBe('https://example.com/proxy/vod/episode/episode-123');
+ });
+
+ it('should generate stream URL with stream_id parameter', () => {
+ const episode = { uuid: 'episode-123' };
+ const provider = {
+ stream_id: 'stream-456',
+ m3u_account: { id: 1 }
+ };
+ const url = getEpisodeStreamUrl(episode, provider, 'prod');
+ expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?stream_id=stream-456');
+ });
+
+ it('should generate stream URL with m3u_account_id when no stream_id', () => {
+ const episode = { uuid: 'episode-123' };
+ const provider = {
+ m3u_account: { id: 1 }
+ };
+ const url = getEpisodeStreamUrl(episode, provider, 'prod');
+ expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?m3u_account_id=1');
+ });
+
+ it('should use dev port in dev mode', () => {
+ const episode = { uuid: 'episode-123' };
+ const url = getEpisodeStreamUrl(episode, null, 'dev');
+ expect(url).toBe('https://example.com:5656/proxy/vod/episode/episode-123');
+ });
+
+ it('should encode special characters in stream_id', () => {
+ const episode = { uuid: 'episode-123' };
+ const provider = {
+ stream_id: 'stream with spaces',
+ m3u_account: { id: 1 }
+ };
+ const url = getEpisodeStreamUrl(episode, provider, 'prod');
+ expect(url).toContain('stream%20with%20spaces');
+ });
+ });
+
+ describe('getYouTubeEmbedUrl', () => {
+ it('should convert youtube.com watch URL to embed URL', () => {
+ const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
+ expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
+ });
+
+ it('should convert youtu.be URL to embed URL', () => {
+ const url = 'https://youtu.be/dQw4w9WgXcQ';
+ expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
+ });
+
+ it('should handle bare video ID', () => {
+ const videoId = 'dQw4w9WgXcQ';
+ expect(getYouTubeEmbedUrl(videoId)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
+ });
+
+ it('should return empty string for null', () => {
+ expect(getYouTubeEmbedUrl(null)).toBe('');
+ });
+
+ it('should return empty string for empty string', () => {
+ expect(getYouTubeEmbedUrl('')).toBe('');
+ });
+ });
+
+ describe('getEpisodeAirdate', () => {
+ it('should format valid air date', () => {
+ const episode = { air_date: '2024-01-15' };
+ const formatted = getEpisodeAirdate(episode);
+ expect(formatted).toMatch(/1\/1[4|5]\/2024/);
+ });
+
+ it('should return N/A for missing air date', () => {
+ const episode = {};
+ expect(getEpisodeAirdate(episode)).toBe('N/A');
+ });
+
+ it('should return N/A for null air date', () => {
+ const episode = { air_date: null };
+ expect(getEpisodeAirdate(episode)).toBe('N/A');
+ });
+ });
+
+ describe('getTmdbUrlLink', () => {
+ const displaySeries = { tmdb_id: '12345' };
+
+ it('should return TV show URL without episode details', () => {
+ const episode = {};
+ const url = getTmdbUrlLink(displaySeries, episode);
+ expect(url).toBe('https://www.themoviedb.org/tv/12345');
+ });
+
+ it('should return TV show URL with season and episode', () => {
+ const episode = { season_number: 2, episode_number: 5 };
+ const url = getTmdbUrlLink(displaySeries, episode);
+ expect(url).toBe('https://www.themoviedb.org/tv/12345/season/2/episode/5');
+ });
+
+ it('should not append episode details if only season is present', () => {
+ const episode = { season_number: 2 };
+ const url = getTmdbUrlLink(displaySeries, episode);
+ expect(url).toBe('https://www.themoviedb.org/tv/12345');
+ });
+
+ it('should not append episode details if only episode is present', () => {
+ const episode = { episode_number: 5 };
+ const url = getTmdbUrlLink(displaySeries, episode);
+ expect(url).toBe('https://www.themoviedb.org/tv/12345');
+ });
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/VODModalUtils.test.js b/frontend/src/utils/components/__tests__/VODModalUtils.test.js
new file mode 100644
index 00000000..04a5edb4
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/VODModalUtils.test.js
@@ -0,0 +1,344 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import {
+ getTechnicalDetails,
+ getMovieStreamUrl,
+ formatVideoDetails,
+ formatAudioDetails,
+} from '../VODModalUtils';
+
+describe('VODModalUtils', () => {
+ describe('getTechnicalDetails', () => {
+ const defaultVOD = {
+ bitrate: '5000 kbps',
+ video: 'H.264',
+ audio: 'AAC',
+ };
+
+ it('should return defaultVOD details when no provider selected', () => {
+ const result = getTechnicalDetails(null, defaultVOD);
+
+ expect(result).toEqual({
+ bitrate: '5000 kbps',
+ video: 'H.264',
+ audio: 'AAC',
+ });
+ });
+
+ it('should extract details from movie content', () => {
+ const provider = {
+ movie: {
+ bitrate: '8000 kbps',
+ video: 'H.265',
+ audio: 'AC3',
+ },
+ };
+
+ const result = getTechnicalDetails(provider, defaultVOD);
+
+ expect(result).toEqual({
+ bitrate: '8000 kbps',
+ video: 'H.265',
+ audio: 'AC3',
+ });
+ });
+
+ it('should extract details from episode content', () => {
+ const provider = {
+ episode: {
+ bitrate: '6000 kbps',
+ video: 'AVC',
+ audio: 'DTS',
+ },
+ };
+
+ const result = getTechnicalDetails(provider, defaultVOD);
+
+ expect(result).toEqual({
+ bitrate: '6000 kbps',
+ video: 'AVC',
+ audio: 'DTS',
+ });
+ });
+
+ it('should extract details from provider object directly', () => {
+ const provider = {
+ bitrate: '7000 kbps',
+ video: 'VP9',
+ audio: 'Opus',
+ };
+
+ const result = getTechnicalDetails(provider, defaultVOD);
+
+ expect(result).toEqual({
+ bitrate: '7000 kbps',
+ video: 'VP9',
+ audio: 'Opus',
+ });
+ });
+
+ it('should extract details from custom_properties.detailed_info', () => {
+ const provider = {
+ custom_properties: {
+ detailed_info: {
+ bitrate: '9000 kbps',
+ video: 'AV1',
+ audio: 'EAC3',
+ },
+ },
+ };
+
+ const result = getTechnicalDetails(provider, defaultVOD);
+
+ expect(result).toEqual({
+ bitrate: '9000 kbps',
+ video: 'AV1',
+ audio: 'EAC3',
+ });
+ });
+
+ it('should fallback to defaultVOD when provider has no valid details', () => {
+ const provider = {
+ custom_properties: {},
+ };
+
+ const result = getTechnicalDetails(provider, defaultVOD);
+
+ expect(result).toEqual({
+ bitrate: '5000 kbps',
+ video: 'H.264',
+ audio: 'AAC',
+ });
+ });
+
+ it('should prioritize movie content over provider object', () => {
+ const provider = {
+ movie: {
+ bitrate: '8000 kbps',
+ },
+ bitrate: '7000 kbps',
+ };
+
+ const result = getTechnicalDetails(provider, defaultVOD);
+
+ expect(result.bitrate).toBe('8000 kbps');
+ });
+
+ it('should handle undefined defaultVOD', () => {
+ const result = getTechnicalDetails(null, undefined);
+
+ expect(result).toEqual({
+ bitrate: undefined,
+ video: undefined,
+ audio: undefined,
+ });
+ });
+ });
+
+ describe('getMovieStreamUrl', () => {
+ const vod = { uuid: 'test-uuid-123' };
+ const originalLocation = window.location;
+
+ beforeEach(() => {
+ delete window.location;
+ window.location = {
+ protocol: 'https:',
+ hostname: 'example.com',
+ origin: 'https://example.com',
+ };
+ });
+
+ afterEach(() => {
+ window.location = originalLocation;
+ });
+
+ it('should generate basic stream URL without provider', () => {
+ const result = getMovieStreamUrl(vod, null, 'production');
+
+ expect(result).toBe('https://example.com/proxy/vod/movie/test-uuid-123');
+ });
+
+ it('should include stream_id when available', () => {
+ const provider = {
+ stream_id: 'stream-123',
+ m3u_account: { id: 'account-456' },
+ };
+
+ const result = getMovieStreamUrl(vod, provider, 'production');
+
+ expect(result).toBe(
+ 'https://example.com/proxy/vod/movie/test-uuid-123?stream_id=stream-123'
+ );
+ });
+
+ it('should include m3u_account_id when stream_id not available', () => {
+ const provider = {
+ m3u_account: { id: 'account-456' },
+ };
+
+ const result = getMovieStreamUrl(vod, provider, 'production');
+
+ expect(result).toBe(
+ 'https://example.com/proxy/vod/movie/test-uuid-123?m3u_account_id=account-456'
+ );
+ });
+
+ it('should use dev port in dev mode', () => {
+ const result = getMovieStreamUrl(vod, null, 'dev');
+
+ expect(result).toBe(
+ 'https://example.com:5656/proxy/vod/movie/test-uuid-123'
+ );
+ });
+
+ it('should encode stream_id', () => {
+ const provider = {
+ stream_id: 'stream with spaces',
+ };
+
+ const result = getMovieStreamUrl(vod, provider, 'production');
+
+ expect(result).toContain('stream_id=stream%20with%20spaces');
+ });
+ });
+
+ describe('formatVideoDetails', () => {
+ it('should format complete video details', () => {
+ const video = {
+ codec_name: 'h264',
+ codec_long_name: 'H.264 / AVC / MPEG-4 AVC',
+ profile: 'High',
+ width: 1920,
+ height: 1080,
+ display_aspect_ratio: '16:9',
+ bit_rate: '8000000',
+ r_frame_rate: '23.98',
+ tags: { encoder: 'x264' },
+ };
+
+ const result = formatVideoDetails(video);
+
+ expect(result).toBe(
+ 'H.264 / AVC / MPEG-4 AVC, (High), 1920x1080, Aspect Ratio: ' +
+ '16:9, Bitrate: 8000 kbps, Frame Rate: 23.98 fps, Encoder: x264'
+ );
+ });
+
+ it('should use codec_name when codec_long_name is unknown', () => {
+ const video = {
+ codec_name: 'h264',
+ codec_long_name: 'unknown',
+ };
+
+ const result = formatVideoDetails(video);
+
+ expect(result).toBe('h264');
+ });
+
+ it('should use codec_name when codec_long_name is not available', () => {
+ const video = {
+ codec_name: 'h265',
+ };
+
+ const result = formatVideoDetails(video);
+
+ expect(result).toBe('h265');
+ });
+
+ it('should handle minimal video details', () => {
+ const video = {
+ codec_name: 'vp9',
+ };
+
+ const result = formatVideoDetails(video);
+
+ expect(result).toBe('vp9');
+ });
+
+ it('should format bitrate correctly', () => {
+ const video = {
+ codec_name: 'h264',
+ bit_rate: '5500000',
+ };
+
+ const result = formatVideoDetails(video);
+
+ expect(result).toContain('Bitrate: 5500 kbps');
+ });
+ });
+
+ describe('formatAudioDetails', () => {
+ it('should format complete audio details', () => {
+ const audio = {
+ codec_name: 'aac',
+ codec_long_name: 'AAC (Advanced Audio Coding)',
+ profile: 'LC',
+ channel_layout: '5.1',
+ sample_rate: '48000',
+ bit_rate: '256000',
+ tags: { handler_name: 'SoundHandler' },
+ };
+
+ const result = formatAudioDetails(audio);
+
+ expect(result).toBe(
+ 'AAC (Advanced Audio Coding), (LC), Channels: 5.1, ' +
+ 'Sample Rate: 48000 Hz, Bitrate: 256 kbps, Handler: SoundHandler'
+ );
+ });
+
+ it('should use codec_name when codec_long_name is unknown', () => {
+ const audio = {
+ codec_name: 'ac3',
+ codec_long_name: 'unknown',
+ };
+
+ const result = formatAudioDetails(audio);
+
+ expect(result).toBe('ac3');
+ });
+
+ it('should use channels count when channel_layout not available', () => {
+ const audio = {
+ codec_name: 'aac',
+ channels: '2',
+ };
+
+ const result = formatAudioDetails(audio);
+
+ expect(result).toBe('aac, Channels: 2');
+ });
+
+ it('should handle minimal audio details', () => {
+ const audio = {
+ codec_name: 'opus',
+ };
+
+ const result = formatAudioDetails(audio);
+
+ expect(result).toBe('opus');
+ });
+
+ it('should format bitrate correctly', () => {
+ const audio = {
+ codec_name: 'aac',
+ bit_rate: '192000',
+ };
+
+ const result = formatAudioDetails(audio);
+
+ expect(result).toContain('Bitrate: 192 kbps');
+ });
+
+ it('should prefer channel_layout over channels', () => {
+ const audio = {
+ codec_name: 'dts',
+ channel_layout: '7.1',
+ channels: '8',
+ };
+
+ const result = formatAudioDetails(audio);
+
+ expect(result).toContain('Channels: 7.1');
+ });
+ });
+});
From b595e845bbcaeaa82ab96f6d2f9d198fcbb6cc87 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 24 Feb 2026 00:59:41 -0800
Subject: [PATCH 02/15] Extracted utils
---
.../utils/components/FloatingVideoUtils.js | 124 +++++++++++++
.../src/utils/components/SeriesModalUtils.js | 166 ++++++++++++++++++
.../src/utils/components/VODModalUtils.js | 124 +++++++++++++
3 files changed, 414 insertions(+)
create mode 100644 frontend/src/utils/components/FloatingVideoUtils.js
create mode 100644 frontend/src/utils/components/SeriesModalUtils.js
create mode 100644 frontend/src/utils/components/VODModalUtils.js
diff --git a/frontend/src/utils/components/FloatingVideoUtils.js b/frontend/src/utils/components/FloatingVideoUtils.js
new file mode 100644
index 00000000..a14f511e
--- /dev/null
+++ b/frontend/src/utils/components/FloatingVideoUtils.js
@@ -0,0 +1,124 @@
+export const getLivePlayerErrorMessage = (errorType, errorDetail) => {
+ if (errorType !== 'MediaError') {
+ return errorDetail
+ ? `Error: ${errorType} - ${errorDetail}`
+ : `Error: ${errorType}`;
+ }
+
+ const errorString = errorDetail?.toLowerCase() || '';
+
+ if (
+ errorString.includes('audio') ||
+ errorString.includes('ac3') ||
+ errorString.includes('ac-3')
+ ) {
+ return 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
+ }
+
+ if (
+ errorString.includes('video') ||
+ errorString.includes('h264') ||
+ errorString.includes('h.264')
+ ) {
+ return 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
+ }
+
+ if (errorString.includes('mse')) {
+ return "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
+ }
+
+ return 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
+};
+
+export const getVODPlayerErrorMessage = (error) => {
+ if (!error) return 'Video playback error';
+
+ switch (error.code) {
+ case error.MEDIA_ERR_ABORTED:
+ return 'Video playback was aborted';
+ case error.MEDIA_ERR_NETWORK:
+ return 'Network error while loading video';
+ case error.MEDIA_ERR_DECODE:
+ return 'Video codec not supported by your browser';
+ case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
+ return 'Video format not supported by your browser';
+ default:
+ return error.message || 'Unknown video error';
+ }
+};
+
+export const getClientCoordinates = (event) => ({
+ clientX: event.touches?.[0]?.clientX ?? event.clientX,
+ clientY: event.touches?.[0]?.clientY ?? event.clientY,
+});
+
+export const calculateNewDimensions = (
+ deltaX,
+ deltaY,
+ startWidth,
+ startHeight,
+ handle,
+ ratio
+) => {
+ const widthDelta = deltaX * handle.xDir;
+ const heightDelta = deltaY * handle.yDir;
+
+ let width = startWidth + widthDelta;
+ let height = width / ratio;
+
+ // Use vertical-driven resize if user drags mostly vertically
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
+ height = startHeight + heightDelta;
+ width = height * ratio;
+ }
+
+ return { width, height };
+};
+
+export const applyConstraints = (
+ width,
+ height,
+ ratio,
+ startPos,
+ handle,
+ minWidth,
+ minHeight,
+ visibleMargin
+) => {
+ // Apply minimum constraints
+ if (width < minWidth) {
+ width = minWidth;
+ height = width / ratio;
+ }
+ if (height < minHeight) {
+ height = minHeight;
+ width = height * ratio;
+ }
+
+ // Apply viewport constraints
+ const posX = startPos?.x ?? 0;
+ const posY = startPos?.y ?? 0;
+ const maxWidth = !handle.isLeft
+ ? Math.max(minWidth, window.innerWidth - posX - visibleMargin)
+ : null;
+ const maxHeight = !handle.isTop
+ ? Math.max(minHeight, window.innerHeight - posY - visibleMargin)
+ : null;
+
+ if (maxWidth && width > maxWidth) {
+ width = maxWidth;
+ height = width / ratio;
+ }
+ if (maxHeight && height > maxHeight) {
+ height = maxHeight;
+ width = height * ratio;
+ }
+
+ // Final adjustment to maintain aspect ratio
+ if (maxWidth && width > maxWidth) {
+ width = maxWidth;
+ height = width / ratio;
+ }
+
+ return { width, height };
+};
diff --git a/frontend/src/utils/components/SeriesModalUtils.js b/frontend/src/utils/components/SeriesModalUtils.js
new file mode 100644
index 00000000..11ab2697
--- /dev/null
+++ b/frontend/src/utils/components/SeriesModalUtils.js
@@ -0,0 +1,166 @@
+export const imdbUrl = (imdb_id) =>
+ imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
+
+export const tmdbUrl = (tmdb_id, type = 'movie') =>
+ tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
+
+export const formatDuration = (seconds) => {
+ if (!seconds) return '';
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
+};
+
+export const formatStreamLabel = (relation) => {
+ const provider = relation.m3u_account.name;
+ const streamId = relation.stream_id;
+ const quality = extractQuality(relation);
+
+ return `${provider}${quality ?? ''}${streamId ? ` (Stream ${streamId})` : ''}`;
+};
+
+const extractQuality = (relation) => {
+ // 1. Primary: Backend quality_info field
+ const fromQualityInfo = getQualityFromBackend(relation.quality_info);
+ if (fromQualityInfo) return fromQualityInfo;
+
+ // 2. Secondary: Custom properties detailed_info
+ if (relation.custom_properties?.detailed_info) {
+ const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info);
+ if (fromDetailedInfo) return fromDetailedInfo;
+ }
+
+ // 3. Fallback: Stream name
+ if (relation.stream_name) {
+ return getQualityFromStreamName(relation.stream_name);
+ }
+
+ return '';
+};
+
+const getQualityFromBackend = (qualityInfo) => {
+ if (!qualityInfo) return '';
+
+ if (qualityInfo.quality) {
+ return ` - ${qualityInfo.quality}`;
+ } else if (qualityInfo.resolution) {
+ return ` - ${qualityInfo.resolution}`;
+ } else if (qualityInfo.bitrate) {
+ return ` - ${qualityInfo.bitrate}`;
+ }
+ return '';
+};
+
+const getQualityFromDetailedInfo = (detailedInfo) => {
+ // Check video dimensions first
+ if (detailedInfo.video?.width && detailedInfo.video?.height) {
+ return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height);
+ }
+
+ // Check name field
+ if (detailedInfo.name) {
+ return parseQualityFromText(detailedInfo.name);
+ }
+
+ return '';
+};
+
+const getQualityFromStreamName = (streamName) => {
+ return parseQualityFromText(streamName);
+};
+
+const parseQualityFromText = (text) => {
+ if (text.includes('4K') || text.includes('2160p')) return ' - 4K';
+ if (text.includes('1080p') || text.includes('FHD')) return ' - 1080p';
+ if (text.includes('720p') || text.includes('HD')) return ' - 720p';
+ if (text.includes('480p')) return ' - 480p';
+ return '';
+};
+
+const getQualityInfoFromDimensions = (width, height) => {
+ // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
+ if (width >= 3840) {
+ return ' - 4K';
+ } else if (width >= 1920) {
+ return ' - 1080p';
+ } else if (width >= 1280) {
+ return ' - 720p';
+ } else if (width >= 854) {
+ return ' - 480p';
+ } else {
+ return ` - ${width}x${height}`;
+ }
+}
+
+export const sortEpisodesList = (episodesList) => {
+ return episodesList.sort((a, b) => {
+ if (a.season_number !== b.season_number) {
+ return (a.season_number || 0) - (b.season_number || 0);
+ }
+ return (a.episode_number || 0) - (b.episode_number || 0);
+ });
+};
+
+export const groupEpisodesBySeason = (seriesEpisodes) => {
+ const grouped = {};
+ seriesEpisodes.forEach((episode) => {
+ const season = episode.season_number || 1;
+ if (!grouped[season]) {
+ grouped[season] = [];
+ }
+ grouped[season].push(episode);
+ });
+ return grouped;
+};
+
+export const sortBySeasonNumber = (episodesBySeason) => {
+ return Object.keys(episodesBySeason)
+ .map(Number)
+ .sort((a, b) => a - b);
+};
+
+export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => {
+ let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
+
+ // Add selected provider as query parameter if available
+ if (selectedProvider) {
+ // Use stream_id for most specific selection, fallback to account_id
+ if (selectedProvider.stream_id) {
+ streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
+ } else {
+ streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
+ }
+ }
+
+ if (env_mode === 'dev') {
+ streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
+ } else {
+ streamUrl = `${window.location.origin}${streamUrl}`;
+ }
+ return streamUrl;
+};
+
+// Helper to get embeddable YouTube URL
+export const getYouTubeEmbedUrl = (url) => {
+ if (!url) return '';
+ // Accepts full YouTube URLs or just IDs
+ const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
+ const videoId = match ? match[1] : url;
+ return `https://www.youtube.com/embed/${videoId}`;
+};
+
+export const getEpisodeAirdate = (episode) => {
+ return episode.air_date
+ ? new Date(episode.air_date).toLocaleDateString()
+ : 'N/A';
+};
+
+export const getTmdbUrlLink = (displaySeries, episode) => {
+ return (
+ tmdbUrl(displaySeries.tmdb_id, 'tv') +
+ (episode.season_number && episode.episode_number
+ ? `/season/${episode.season_number}/episode/${episode.episode_number}`
+ : '')
+ );
+};
diff --git a/frontend/src/utils/components/VODModalUtils.js b/frontend/src/utils/components/VODModalUtils.js
new file mode 100644
index 00000000..111fd477
--- /dev/null
+++ b/frontend/src/utils/components/VODModalUtils.js
@@ -0,0 +1,124 @@
+const hasValidTechnicalDetails = (obj) => {
+ return obj?.bitrate || obj?.video || obj?.audio;
+};
+
+const extractFromDetailedInfo = (customProperties) => {
+ const detailedInfo = customProperties?.detailed_info;
+ if (!detailedInfo) return null;
+
+ return {
+ bitrate: detailedInfo.bitrate || null,
+ video: detailedInfo.video || null,
+ audio: detailedInfo.audio || null,
+ };
+};
+
+export const getTechnicalDetails = (selectedProvider, defaultVOD) => {
+ if (!selectedProvider) {
+ return {
+ bitrate: defaultVOD?.bitrate,
+ video: defaultVOD?.video,
+ audio: defaultVOD?.audio,
+ };
+ }
+
+ // Try movie/episode content first
+ const content = selectedProvider.movie || selectedProvider.episode;
+ if (content && hasValidTechnicalDetails(content)) {
+ return {
+ bitrate: content.bitrate,
+ video: content.video,
+ audio: content.audio,
+ };
+ }
+
+ // Try provider object directly
+ if (hasValidTechnicalDetails(selectedProvider)) {
+ return {
+ bitrate: selectedProvider.bitrate,
+ video: selectedProvider.video,
+ audio: selectedProvider.audio,
+ };
+ }
+
+ // Try custom_properties.detailed_info
+ const detailedInfo = extractFromDetailedInfo(
+ selectedProvider.custom_properties
+ );
+ if (detailedInfo && hasValidTechnicalDetails(detailedInfo)) {
+ return detailedInfo;
+ }
+
+ // Fallback to defaultVOD
+ return {
+ bitrate: defaultVOD?.bitrate,
+ video: defaultVOD?.video,
+ audio: defaultVOD?.audio,
+ };
+};
+
+export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => {
+ let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
+
+ // Add selected provider as query parameter if available
+ if (selectedProvider) {
+ // Use stream_id for most specific selection, fallback to account_id
+ if (selectedProvider.stream_id) {
+ streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
+ } else {
+ streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
+ }
+ }
+
+ if (env_mode === 'dev') {
+ streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
+ } else {
+ streamUrl = `${window.location.origin}${streamUrl}`;
+ }
+ return streamUrl;
+};
+
+export const formatVideoDetails = (video) => {
+ const parts = [];
+
+ const codec =
+ video.codec_long_name && video.codec_long_name !== 'unknown'
+ ? video.codec_long_name
+ : video.codec_name;
+ parts.push(codec);
+
+ if (video.profile) parts.push(`(${video.profile})`);
+ if (video.width && video.height) parts.push(`${video.width}x${video.height}`);
+ if (video.display_aspect_ratio)
+ parts.push(`Aspect Ratio: ${video.display_aspect_ratio}`);
+ if (video.bit_rate)
+ parts.push(`Bitrate: ${Math.round(Number(video.bit_rate) / 1000)} kbps`);
+ if (video.r_frame_rate) parts.push(`Frame Rate: ${video.r_frame_rate} fps`);
+ if (video.tags?.encoder) parts.push(`Encoder: ${video.tags.encoder}`);
+
+ return parts.join(', ');
+};
+
+export const formatAudioDetails = (audio) => {
+ const parts = [];
+
+ const codec =
+ audio.codec_long_name && audio.codec_long_name !== 'unknown'
+ ? audio.codec_long_name
+ : audio.codec_name;
+ parts.push(codec);
+
+ if (audio.profile) parts.push(`(${audio.profile})`);
+
+ const channels =
+ audio.channel_layout || (audio.channels ? `${audio.channels}` : null);
+ if (channels) parts.push(`Channels: ${channels}`);
+
+ if (audio.sample_rate) parts.push(`Sample Rate: ${audio.sample_rate} Hz`);
+ if (audio.bit_rate)
+ parts.push(`Bitrate: ${Math.round(Number(audio.bit_rate) / 1000)} kbps`);
+ if (audio.tags?.handler_name)
+ parts.push(`Handler: ${audio.tags.handler_name}`);
+
+ return parts.join(', ');
+};
From 8f11ef417b52ceec566d5a3c0db2f3af6a24c2c4 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 24 Feb 2026 00:59:59 -0800
Subject: [PATCH 03/15] Extracted YouTubeTrailerModal component
---
.../components/modals/YouTubeTrailerModal.jsx | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
create mode 100644 frontend/src/components/modals/YouTubeTrailerModal.jsx
diff --git a/frontend/src/components/modals/YouTubeTrailerModal.jsx b/frontend/src/components/modals/YouTubeTrailerModal.jsx
new file mode 100644
index 00000000..d873fea0
--- /dev/null
+++ b/frontend/src/components/modals/YouTubeTrailerModal.jsx
@@ -0,0 +1,29 @@
+import { Box, Modal } from '@mantine/core';
+import React from 'react';
+
+export const YouTubeTrailerModal = ({ opened, onClose, trailerUrl }) => {
+ return (
+
+
+ {trailerUrl && (
+
+ )}
+
+
+ );
+};
\ No newline at end of file
From fec4caf4d064d42def7b8f56bdd208ebcac9921b Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Tue, 24 Feb 2026 01:02:26 -0800
Subject: [PATCH 04/15] Component refactoring and cleanup
---
.../src/components/ConfirmationDialog.jsx | 2 +-
frontend/src/components/Field.jsx | 1 +
frontend/src/components/FloatingVideo.jsx | 377 +++---
.../src/components/M3URefreshNotification.jsx | 221 ++--
frontend/src/components/SeriesModal.jsx | 1010 +++++++----------
frontend/src/components/Sidebar.jsx | 39 +-
frontend/src/components/SystemEvents.jsx | 299 ++---
frontend/src/components/VODModal.jsx | 766 +++++--------
8 files changed, 1084 insertions(+), 1631 deletions(-)
diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx
index 94fb169c..86e7d7af 100644
--- a/frontend/src/components/ConfirmationDialog.jsx
+++ b/frontend/src/components/ConfirmationDialog.jsx
@@ -1,4 +1,4 @@
-import { Modal, Group, Text, Button, Checkbox, Box } from '@mantine/core';
+import { Modal, Group, Button, Checkbox, Box } from '@mantine/core';
import React, { useState } from 'react';
import useWarningsStore from '../store/warnings';
diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx
index 1293bf7b..335c55cd 100644
--- a/frontend/src/components/Field.jsx
+++ b/frontend/src/components/Field.jsx
@@ -4,6 +4,7 @@ import React from 'react';
export const Field = ({ field, value, onChange }) => {
const common = { label: field.label, description: field.help_text };
const effective = value ?? field.default;
+
switch (field.type) {
case 'boolean':
return (
diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx
index 557767ed..d25f7468 100644
--- a/frontend/src/components/FloatingVideo.jsx
+++ b/frontend/src/components/FloatingVideo.jsx
@@ -1,39 +1,21 @@
// frontend/src/components/FloatingVideo.js
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, {useCallback, useEffect, useRef, useState} from 'react';
import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import mpegts from 'mpegts.js';
-import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
+import {Box, CloseButton, Flex, Loader, Text} from '@mantine/core';
+import {
+ applyConstraints,
+ calculateNewDimensions,
+ getClientCoordinates,
+ getLivePlayerErrorMessage,
+ getVODPlayerErrorMessage,
+} from '../utils/components/FloatingVideoUtils.js';
-export default function FloatingVideo() {
- const isVisible = useVideoStore((s) => s.isVisible);
- const streamUrl = useVideoStore((s) => s.streamUrl);
- const contentType = useVideoStore((s) => s.contentType);
- const metadata = useVideoStore((s) => s.metadata);
- const hideVideo = useVideoStore((s) => s.hideVideo);
- const videoRef = useRef(null);
- const playerRef = useRef(null);
- const videoContainerRef = useRef(null);
- const [isLoading, setIsLoading] = useState(false);
- const [loadError, setLoadError] = useState(null);
- const [showOverlay, setShowOverlay] = useState(true);
- const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
- const [isResizing, setIsResizing] = useState(false);
- const resizeStateRef = useRef(null);
- const overlayTimeoutRef = useRef(null);
- const aspectRatioRef = useRef(320 / 180);
- const [dragPosition, setDragPosition] = useState(null);
- const dragPositionRef = useRef(null);
- const dragOffsetRef = useRef({ x: 0, y: 0 });
- const initialPositionRef = useRef(null);
-
- const MIN_WIDTH = 220;
- const MIN_HEIGHT = 124;
- const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
- const HEADER_HEIGHT = 38; // height of the close button header area
- const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
+const ResizeHandles = ({ startResize }) => {
const HANDLE_SIZE = 18;
const HANDLE_OFFSET = 0;
+
const resizeHandleBaseStyle = {
position: 'absolute',
width: HANDLE_SIZE,
@@ -43,6 +25,7 @@ export default function FloatingVideo() {
zIndex: 8,
touchAction: 'none',
};
+
const resizeHandles = [
{
id: 'bottom-right',
@@ -106,6 +89,56 @@ export default function FloatingVideo() {
},
];
+ return (
+ <>
+ {/* Resize handles */}
+ {resizeHandles.map((handle) => (
+ startResize(event, handle)}
+ onTouchStart={(event) => startResize(event, handle)}
+ style={{
+ ...resizeHandleBaseStyle,
+ ...handle.style,
+ cursor: handle.cursor,
+ }}
+ />
+ ))}
+ >
+ );
+}
+
+export default function FloatingVideo() {
+ const isVisible = useVideoStore((s) => s.isVisible);
+ const streamUrl = useVideoStore((s) => s.streamUrl);
+ const contentType = useVideoStore((s) => s.contentType);
+ const metadata = useVideoStore((s) => s.metadata);
+ const hideVideo = useVideoStore((s) => s.hideVideo);
+
+ const videoRef = useRef(null);
+ const playerRef = useRef(null);
+ const videoContainerRef = useRef(null);
+ const resizeStateRef = useRef(null);
+ const overlayTimeoutRef = useRef(null);
+ const aspectRatioRef = useRef(320 / 180);
+ const dragPositionRef = useRef(null);
+ const dragOffsetRef = useRef({ x: 0, y: 0 });
+ const initialPositionRef = useRef(null);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [loadError, setLoadError] = useState(null);
+ const [showOverlay, setShowOverlay] = useState(true);
+ const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
+ const [isResizing, setIsResizing] = useState(false);
+ const [dragPosition, setDragPosition] = useState(null);
+
+ const MIN_WIDTH = 220;
+ const MIN_HEIGHT = 124;
+ const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
+ const HEADER_HEIGHT = 38; // height of the close button header area
+ const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
+
// Safely destroy the mpegts player to prevent errors
const safeDestroyPlayer = () => {
try {
@@ -190,41 +223,20 @@ export default function FloatingVideo() {
};
const handleError = (e) => {
setIsLoading(false);
- const error = e.target.error;
- let errorMessage = 'Video playback error';
- if (error) {
- switch (error.code) {
- case error.MEDIA_ERR_ABORTED:
- errorMessage = 'Video playback was aborted';
- break;
- case error.MEDIA_ERR_NETWORK:
- errorMessage = 'Network error while loading video';
- break;
- case error.MEDIA_ERR_DECODE:
- errorMessage = 'Video codec not supported by your browser';
- break;
- case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
- errorMessage = 'Video format not supported by your browser';
- break;
- default:
- errorMessage = error.message || 'Unknown video error';
- }
- }
-
- setLoadError(errorMessage);
+ setLoadError(getVODPlayerErrorMessage(e.target.error));
};
// Enhanced progress tracking for VOD
const handleProgress = () => {
- if (video.buffered.length > 0) {
- const bufferedEnd = video.buffered.end(video.buffered.length - 1);
- const duration = video.duration;
- if (duration > 0) {
- const bufferedPercent = (bufferedEnd / duration) * 100;
- // You could emit this to a store for UI feedback
- }
- }
+ // if (video.buffered.length > 0) {
+ // const bufferedEnd = video.buffered.end(video.buffered.length - 1);
+ // const duration = video.duration;
+ // if (duration > 0) {
+ // const bufferedPercent = (bufferedEnd / duration) * 100;
+ // // You could emit this to a store for UI feedback
+ // }
+ // }
};
// Add event listeners
@@ -301,37 +313,7 @@ export default function FloatingVideo() {
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
console.error('Player error:', errorType, errorDetail);
- let errorMessage = `Error: ${errorType}`;
-
- if (errorType === 'MediaError') {
- const errorString = errorDetail?.toLowerCase() || '';
-
- if (
- errorString.includes('audio') ||
- errorString.includes('ac3') ||
- errorString.includes('ac-3')
- ) {
- errorMessage =
- 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
- } else if (
- errorString.includes('video') ||
- errorString.includes('h264') ||
- errorString.includes('h.264')
- ) {
- errorMessage =
- 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
- } else if (errorString.includes('mse')) {
- errorMessage =
- "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
- } else {
- errorMessage =
- 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
- }
- } else if (errorDetail) {
- errorMessage += ` - ${errorDetail}`;
- }
-
- setLoadError(errorMessage);
+ setLoadError(getLivePlayerErrorMessage(errorType, errorDetail));
}
});
@@ -448,15 +430,7 @@ export default function FloatingVideo() {
(event) => {
if (!resizeStateRef.current) return;
- const clientX =
- event.touches && event.touches.length
- ? event.touches[0].clientX
- : event.clientX;
- const clientY =
- event.touches && event.touches.length
- ? event.touches[0].clientY
- : event.clientY;
-
+ const { clientX, clientY } = getClientCoordinates(event);
const {
startX,
startY,
@@ -466,104 +440,62 @@ export default function FloatingVideo() {
handle,
aspectRatio,
} = resizeStateRef.current;
- const deltaX = clientX - startX;
- const deltaY = clientY - startY;
- const widthDelta = deltaX * handle.xDir;
- const heightDelta = deltaY * handle.yDir;
+
const ratio = aspectRatio || aspectRatioRef.current;
+ const { width: nextWidth, height: nextHeight } = calculateNewDimensions(
+ clientX - startX,
+ clientY - startY,
+ startWidth,
+ startHeight,
+ handle,
+ ratio
+ );
- // Derive width/height while keeping the original aspect ratio
- let nextWidth = startWidth + widthDelta;
- let nextHeight = nextWidth / ratio;
-
- // Allow vertical-driven resize if the user drags mostly vertically
- if (Math.abs(deltaY) > Math.abs(deltaX)) {
- nextHeight = startHeight + heightDelta;
- nextWidth = nextHeight * ratio;
- }
-
- // Respect minimums while keeping the ratio
- if (nextWidth < MIN_WIDTH) {
- nextWidth = MIN_WIDTH;
- nextHeight = nextWidth / ratio;
- }
-
- if (nextHeight < MIN_HEIGHT) {
- nextHeight = MIN_HEIGHT;
- nextWidth = nextHeight * ratio;
- }
-
- // Keep within viewport with a margin based on current position
- const posX = startPos?.x ?? 0;
- const posY = startPos?.y ?? 0;
- const margin = VISIBLE_MARGIN;
- let maxWidth = null;
- let maxHeight = null;
-
- if (!handle.isLeft) {
- maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin);
- }
-
- if (!handle.isTop) {
- maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin);
- }
-
- if (maxWidth != null && nextWidth > maxWidth) {
- nextWidth = maxWidth;
- nextHeight = nextWidth / ratio;
- }
-
- if (maxHeight != null && nextHeight > maxHeight) {
- nextHeight = maxHeight;
- nextWidth = nextHeight * ratio;
- }
-
- // Final pass to honor both bounds while keeping the ratio
- if (maxWidth != null && nextWidth > maxWidth) {
- nextWidth = maxWidth;
- nextHeight = nextWidth / ratio;
- }
+ const constrainedSize = applyConstraints(
+ nextWidth,
+ nextHeight,
+ ratio,
+ startPos,
+ handle,
+ MIN_WIDTH,
+ MIN_HEIGHT,
+ VISIBLE_MARGIN
+ );
setVideoSize({
- width: Math.round(nextWidth),
- height: Math.round(nextHeight),
+ width: Math.round(constrainedSize.width),
+ height: Math.round(constrainedSize.height),
});
- if (handle.isLeft || handle.isTop) {
- let nextX = posX;
- let nextY = posY;
-
- if (handle.isLeft) {
- nextX = posX + (startWidth - nextWidth);
- }
-
- if (handle.isTop) {
- nextY = posY + (startHeight - nextHeight);
- }
-
- const clamped = clampToVisibleWithSize(
- nextX,
- nextY,
- nextWidth,
- nextHeight
- );
-
- if (handle.isLeft) {
- nextX = clamped.x;
- }
-
- if (handle.isTop) {
- nextY = clamped.y;
- }
-
- const nextPos = { x: nextX, y: nextY };
- setDragPosition(nextPos);
- dragPositionRef.current = nextPos;
- }
+ updatePositionIfNeeded(
+ handle,
+ startPos,
+ startWidth,
+ startHeight,
+ constrainedSize
+ );
},
[MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize]
);
+ const updatePositionIfNeeded = (handle, startPos, startWidth, startHeight, newSize) => {
+ if (!handle.isLeft && !handle.isTop) return;
+
+ const posX = startPos?.x ?? 0;
+ const posY = startPos?.y ?? 0;
+ let nextX = handle.isLeft ? posX + (startWidth - newSize.width) : posX;
+ let nextY = handle.isTop ? posY + (startHeight - newSize.height) : posY;
+
+ const clamped = clampToVisibleWithSize(nextX, nextY, newSize.width, newSize.height);
+ const nextPos = {
+ x: handle.isLeft ? clamped.x : nextX,
+ y: handle.isTop ? clamped.y : nextY,
+ };
+
+ setDragPosition(nextPos);
+ dragPositionRef.current = nextPos;
+ };
+
const endResize = useCallback(() => {
setIsResizing(false);
resizeStateRef.current = null;
@@ -577,14 +509,7 @@ export default function FloatingVideo() {
event.stopPropagation();
event.preventDefault();
- const clientX =
- event.touches && event.touches.length
- ? event.touches[0].clientX
- : event.clientX;
- const clientY =
- event.touches && event.touches.length
- ? event.touches[0].clientY
- : event.clientY;
+ const { clientX, clientY } = getClientCoordinates(event);
const aspectRatio =
videoSize.height > 0
@@ -719,20 +644,15 @@ export default function FloatingVideo() {
}}
>
{/* Simple header row with a close button */}
-
+
e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
+ mih={32}
+ miw={32}
style={{
- minHeight: '32px',
- minWidth: '32px',
cursor: 'pointer',
touchAction: 'manipulation',
}}
@@ -741,7 +661,7 @@ export default function FloatingVideo() {
{/* Video container with relative positioning for the overlay */}
{
if (contentType === 'vod' && !isLoading) {
setShowOverlay(true);
@@ -781,17 +701,17 @@ export default function FloatingVideo() {
{/* VOD title overlay when not loading - auto-hides after 4 seconds */}
{!isLoading && metadata && contentType === 'vod' && showOverlay && (
{metadata.year}
@@ -816,14 +736,14 @@ export default function FloatingVideo() {
{/* Loading overlay - only show when loading */}
{isLoading && (
-
+
Loading {contentType === 'vod' ? 'video' : 'stream'}...
@@ -841,32 +761,19 @@ export default function FloatingVideo() {
{/* Error message below video - doesn't block controls */}
{!isLoading && loadError && (
-
+
{loadError}
)}
- {/* Resize handles */}
- {resizeHandles.map((handle) => (
- startResize(event, handle)}
- onTouchStart={(event) => startResize(event, handle)}
- style={{
- ...resizeHandleBaseStyle,
- ...handle.style,
- cursor: handle.cursor,
- }}
- />
- ))}
+
);
diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx
index b9819b4e..5b40cadb 100644
--- a/frontend/src/components/M3URefreshNotification.jsx
+++ b/frontend/src/components/M3URefreshNotification.jsx
@@ -1,7 +1,6 @@
// frontend/src/components/FloatingVideo.js
import React, { useEffect, useState } from 'react';
import usePlaylistsStore from '../store/playlists';
-import { notifications } from '@mantine/notifications';
import useStreamsStore from '../store/streams';
import useChannelsStore from '../store/channels';
import useEPGsStore from '../store/epgs';
@@ -10,6 +9,39 @@ import { Stack, Button, Group } from '@mantine/core';
import API from '../api';
import { useNavigate } from 'react-router-dom';
import { CircleCheck } from 'lucide-react';
+import { showNotification } from '../utils/notificationUtils.js';
+
+const M3uSetupSuccess = (data) => {
+ const navigate = useNavigate();
+
+ const onClickRefresh = () => {
+ API.refreshPlaylist(data.account);
+ }
+
+ const onClickConfigure = () => {
+ // Store the ID we want to edit in the store first
+ usePlaylistsStore.getState().setEditPlaylistId(data.account);
+
+ // Then navigate to the content sources page
+ // Using the exact path that matches your app's routing structure
+ navigate('/sources');
+ }
+
+ return (
+
+ {data.message ||
+ 'M3U groups loaded. Configure group filters and auto channel sync settings.'}
+
+
+
+
+
+ );
+}
export default function M3URefreshNotification() {
const playlists = usePlaylistsStore((s) => s.playlists);
@@ -22,146 +54,109 @@ export default function M3URefreshNotification() {
const fetchCategories = useVODStore((s) => s.fetchCategories);
const [notificationStatus, setNotificationStatus] = useState({});
- const navigate = useNavigate();
const handleM3UUpdate = (data) => {
- if (
- JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)
- ) {
+ // Skip if status hasn't changed
+ if (JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)) {
return;
}
const playlist = playlists.find((pl) => pl.id == data.account);
- if (!playlist) {
- return;
- }
+ if (!playlist) return;
- // Store the updated status first
- setNotificationStatus({
- ...notificationStatus,
+ // Update notification status
+ setNotificationStatus(prev => ({
+ ...prev,
[data.account]: data,
- });
+ }));
- // Special handling for pending setup status
+ // Handle different status types
if (data.status === 'pending_setup') {
- fetchChannelGroups();
- fetchPlaylists();
-
- notifications.show({
- title: `M3U Setup: ${playlist.name}`,
- message: (
-
- {data.message ||
- 'M3U groups loaded. Configure group filters and auto channel sync settings.'}
-
-
-
-
-
- ),
- color: 'orange.5',
- autoClose: 5000, // Keep visible a bit longer
- });
+ handlePendingSetup(playlist, data);
return;
}
- // Check for error status FIRST before doing anything else
if (data.status === 'error') {
- // Only show the error notification if we have a complete task (progress=100)
- // or if it's explicitly flagged as an error
- if (data.progress === 100) {
- notifications.show({
- title: `M3U Processing: ${playlist.name}`,
- message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
- color: 'red',
- autoClose: 5000, // Keep error visible a bit longer
- });
- }
- return; // Exit early for any error status
- }
-
- // Check if we already have an error stored for this account, and if so, don't show further notifications
- const currentStatus = notificationStatus[data.account];
- if (currentStatus && currentStatus.status === 'error') {
- // Don't show any other notifications once we've hit an error
+ handleError(playlist, data);
return;
}
- const taskProgress = data.progress;
-
- // Only show start and completion notifications for normal operation
- if (data.progress != 0 && data.progress != 100) {
+ // Skip if already errored
+ if (notificationStatus[data.account]?.status === 'error') {
return;
}
- let message = '';
- switch (data.action) {
- case 'downloading':
- message = 'Downloading';
- break;
+ // Handle normal progress updates (0% start, 100% completion)
+ if (data.progress === 0 || data.progress === 100) {
+ handleProgressNotification(playlist, data);
+ }
+ };
- case 'parsing':
- message = 'Stream parsing';
- break;
+ const handlePendingSetup = (playlist, data) => {
+ fetchChannelGroups();
+ fetchPlaylists();
- case 'processing_groups':
- message = 'Group parsing';
- break;
+ showNotification({
+ title: `M3U Setup: ${playlist.name}`,
+ message: ,
+ color: 'orange.5',
+ autoClose: 5000,
+ });
+ };
- case 'vod_refresh':
- message = 'VOD content refresh';
- break;
+ const handleError = (playlist, data) => {
+ if (data.progress === 100) {
+ showNotification({
+ title: `M3U Processing: ${playlist.name}`,
+ message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
+ color: 'red',
+ autoClose: 5000,
+ });
+ }
+ };
+
+ const getActionMessage = (action) => {
+ const messages = {
+ downloading: 'Downloading',
+ parsing: 'Stream parsing',
+ processing_groups: 'Group parsing',
+ vod_refresh: 'VOD content refresh',
+ };
+ return messages[action] || 'Processing';
+ };
+
+ const triggerPostCompletionFetches = (action) => {
+ if (action == 'parsing') {
+ fetchStreams();
+ API.requeryChannels();
+ fetchChannels();
+ } else if (action == 'processing_groups') {
+ fetchStreams();
+ fetchChannelGroups();
+ fetchEPGData();
+ fetchPlaylists();
+ } else if (action == 'vod_refresh') {
+ fetchPlaylists();
+ fetchCategories();
+ }
+ };
+
+ const handleProgressNotification = (playlist, data) => {
+ const baseMessage = getActionMessage(data.action);
+ const message = data.progress == 0
+ ? `${baseMessage} starting...`
+ : `${baseMessage} complete!`;
+
+ if (data.progress == 100) {
+ triggerPostCompletionFetches(data.action);
}
- if (taskProgress == 0) {
- message = `${message} starting...`;
- } else if (taskProgress == 100) {
- message = `${message} complete!`;
-
- // Only trigger additional fetches on successful completion
- if (data.action == 'parsing') {
- fetchStreams();
- API.requeryChannels();
- fetchChannels();
- } else if (data.action == 'processing_groups') {
- fetchStreams();
- fetchChannelGroups();
- fetchEPGData();
- fetchPlaylists();
- } else if (data.action == 'vod_refresh') {
- // VOD refresh completed, trigger VOD categories refresh
- fetchPlaylists(); // Refresh playlist data to show updated VOD info
- fetchCategories(); // Refresh VOD categories to make them visible
- }
- }
-
- notifications.show({
+ showNotification({
title: `M3U Processing: ${playlist.name}`,
message,
- loading: taskProgress == 0,
+ loading: data.progress == 0,
autoClose: 2000,
- icon: taskProgress == 100 ? : null,
+ icon: data.progress == 100 ? : null,
});
};
diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx
index c8e551af..367e92a1 100644
--- a/frontend/src/components/SeriesModal.jsx
+++ b/frontend/src/components/SeriesModal.jsx
@@ -1,128 +1,345 @@
-import React, { useState, useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
import {
+ ActionIcon,
+ Badge,
Box,
Button,
+ Divider,
Flex,
Group,
Image,
+ Loader,
+ Modal,
+ Select,
+ Stack,
+ Table,
+ TableTbody,
+ TableTd,
+ TableTh,
+ TableThead,
+ TableTr,
+ Tabs,
+ TabsList,
+ TabsPanel,
+ TabsTab,
Text,
Title,
- Select,
- Badge,
- Loader,
- Stack,
- ActionIcon,
- Modal,
- Tabs,
- Table,
- Divider,
} from '@mantine/core';
-import { Play, Copy } from 'lucide-react';
-import { notifications } from '@mantine/notifications';
+import { Copy, Play } from 'lucide-react';
import { copyToClipboard } from '../utils';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
+import {
+ formatDuration,
+ formatStreamLabel,
+ getEpisodeAirdate,
+ getEpisodeStreamUrl,
+ getTmdbUrlLink,
+ getYouTubeEmbedUrl,
+ groupEpisodesBySeason,
+ imdbUrl,
+ sortBySeasonNumber,
+ sortEpisodesList,
+ tmdbUrl,
+} from '../utils/components/SeriesModalUtils.js';
+import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
-const imdbUrl = (imdb_id) =>
- imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
-const tmdbUrl = (tmdb_id, type = 'movie') =>
- tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
-const formatDuration = (seconds) => {
- if (!seconds) return '';
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
- return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
+const Series = ({ displaySeries, onClickYouTubeTrailer }) => {
+ return (
+
+ {displaySeries.series_image || displaySeries.logo?.url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {displaySeries.name}
+
+ {/* Original name if different */}
+ {displaySeries.o_name &&
+ displaySeries.o_name !== displaySeries.name && (
+
+ Original: {displaySeries.o_name}
+
+ )}
+
+
+ {displaySeries.year && (
+ {displaySeries.year}
+ )}
+ {displaySeries.rating && (
+ {displaySeries.rating}
+ )}
+ {displaySeries.age && (
+ {displaySeries.age}
+ )}
+ Series
+ {displaySeries.episode_count && (
+ {displaySeries.episode_count} episodes
+ )}
+ {/* imdb_id and tmdb_id badges */}
+ {displaySeries.imdb_id && (
+
+ IMDb
+
+ )}
+ {displaySeries.tmdb_id && (
+
+ TMDb
+
+ )}
+
+
+ {/* Release date */}
+ {displaySeries.release_date && (
+
+ Release Date: {displaySeries.release_date}
+
+ )}
+
+ {displaySeries.genre && (
+
+ Genre: {displaySeries.genre}
+
+ )}
+
+ {displaySeries.director && (
+
+ Director: {displaySeries.director}
+
+ )}
+
+ {displaySeries.cast && (
+
+ Cast: {displaySeries.cast}
+
+ )}
+
+ {displaySeries.country && (
+
+ Country: {displaySeries.country}
+
+ )}
+
+ {/* Description */}
+ {displaySeries.description && (
+
+
+ Description
+
+ {displaySeries.description}
+
+ )}
+
+ {/* Watch Trailer button if available */}
+ {displaySeries.youtube_trailer && (
+
+ )}
+
+
+ );
};
-const formatStreamLabel = (relation) => {
- // Create a label for the stream that includes provider name and stream-specific info
- const provider = relation.m3u_account.name;
- const streamId = relation.stream_id;
+const Episode = ({ episode, displaySeries }) => {
+ return (
+
+ {/* Episode Image and Description Row */}
+
+ {/* Episode Image */}
+ {episode.movie_image && (
+
+
+
+ )}
- // Try to extract quality info - prioritizing the new quality_info field from backend
- let qualityInfo = '';
+ {/* Episode Description */}
+
+ {episode.description && (
+
+
+ Description
+
+
+ {episode.description}
+
+
+ )}
+
+
- // 1. Check the new quality_info field from backend (PRIMARY)
- if (relation.quality_info) {
- if (relation.quality_info.quality) {
- qualityInfo = ` - ${relation.quality_info.quality}`;
- } else if (relation.quality_info.resolution) {
- qualityInfo = ` - ${relation.quality_info.resolution}`;
- } else if (relation.quality_info.bitrate) {
- qualityInfo = ` - ${relation.quality_info.bitrate}`;
- }
- }
+ {/* Additional Episode Details */}
+
+ {episode.rating && (
+
+
+ Rating
+
+
+ {episode.rating}
+
+
+ )}
+ {/* IMDb and TMDb badges for episode */}
+ {(episode.imdb_id || displaySeries.tmdb_id) && (
+
+
+ Links
+
+ {episode.imdb_id && (
+
+ IMDb
+
+ )}
+ {displaySeries.tmdb_id && (
+
+ TMDb
+
+ )}
+
+ )}
- // 2. Fallback: Check custom_properties detailed info structure
- if (qualityInfo === '' && relation.custom_properties) {
- const props = relation.custom_properties;
+ {episode.director && (
+
+
+ Director
+
+ {episode.director}
+
+ )}
- // Check detailed_info structure (where the real data is!)
- if (qualityInfo === '' && props.detailed_info) {
- const detailedInfo = props.detailed_info;
+ {episode.actors && (
+
+
+ Cast
+
+
+ {episode.actors}
+
+
+ )}
+
- // Extract from video resolution
- if (
- detailedInfo.video &&
- detailedInfo.video.width &&
- detailedInfo.video.height
- ) {
- const width = detailedInfo.video.width;
- const height = detailedInfo.video.height;
+ {/* Technical Details */}
+ {(episode.bitrate || episode.video || episode.audio) && (
+
+
+ Technical Details
+
+
+ {episode.bitrate && episode.bitrate > 0 && (
+
+ Bitrate: {episode.bitrate} kbps
+
+ )}
+ {episode.video && Object.keys(episode.video).length > 0 && (
+
+ Video:{' '}
+ {episode.video.codec_long_name || episode.video.codec_name}
+ {episode.video.width && episode.video.height
+ ? `, ${episode.video.width}x${episode.video.height}`
+ : ''}
+
+ )}
+ {episode.audio && Object.keys(episode.audio).length > 0 && (
+
+ Audio:{' '}
+ {episode.audio.codec_long_name || episode.audio.codec_name}
+ {episode.audio.channels
+ ? `, ${episode.audio.channels} channels`
+ : ''}
+
+ )}
+
+
+ )}
- // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
- if (width >= 3840) {
- qualityInfo = ' - 4K';
- } else if (width >= 1920) {
- qualityInfo = ' - 1080p';
- } else if (width >= 1280) {
- qualityInfo = ' - 720p';
- } else if (width >= 854) {
- qualityInfo = ' - 480p';
- } else {
- qualityInfo = ` - ${width}x${height}`;
- }
- }
-
- // Extract from movie name in detailed_info
- if (qualityInfo === '' && detailedInfo.name) {
- const name = detailedInfo.name;
- if (name.includes('4K') || name.includes('2160p')) {
- qualityInfo = ' - 4K';
- } else if (name.includes('1080p') || name.includes('FHD')) {
- qualityInfo = ' - 1080p';
- } else if (name.includes('720p') || name.includes('HD')) {
- qualityInfo = ' - 720p';
- } else if (name.includes('480p')) {
- qualityInfo = ' - 480p';
- }
- }
- }
- }
-
- // 3. Final fallback: Check stream name for quality markers
- if (qualityInfo === '' && relation.stream_name) {
- const streamName = relation.stream_name;
- if (streamName.includes('4K') || streamName.includes('2160p')) {
- qualityInfo = ' - 4K';
- } else if (streamName.includes('1080p') || streamName.includes('FHD')) {
- qualityInfo = ' - 1080p';
- } else if (streamName.includes('720p') || streamName.includes('HD')) {
- qualityInfo = ' - 720p';
- } else if (streamName.includes('480p')) {
- qualityInfo = ' - 480p';
- }
- }
-
- return `${provider}${qualityInfo}${streamId ? ` (Stream ${streamId})` : ''}`;
+ {/* Provider Information */}
+ {episode.m3u_account && (
+
+
+ Provider:
+
+
+ {episode.m3u_account.name || episode.m3u_account}
+
+
+ )}
+
+ );
};
const SeriesModal = ({ series, opened, onClose }) => {
const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore();
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
+
const [detailedSeries, setDetailedSeries] = useState(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const [activeTab, setActiveTab] = useState(null);
@@ -192,12 +409,7 @@ const SeriesModal = ({ series, opened, onClose }) => {
// Try to get episodes from the fetched data
if (detailedSeries.episodesList) {
- return detailedSeries.episodesList.sort((a, b) => {
- if (a.season_number !== b.season_number) {
- return (a.season_number || 0) - (b.season_number || 0);
- }
- return (a.episode_number || 0) - (b.episode_number || 0);
- });
+ return sortEpisodesList(detailedSeries.episodesList);
}
// If no episodes in detailed series, return empty array
@@ -206,22 +418,12 @@ const SeriesModal = ({ series, opened, onClose }) => {
// Group episodes by season
const episodesBySeason = React.useMemo(() => {
- const grouped = {};
- seriesEpisodes.forEach((episode) => {
- const season = episode.season_number || 1;
- if (!grouped[season]) {
- grouped[season] = [];
- }
- grouped[season].push(episode);
- });
- return grouped;
+ return groupEpisodesBySeason(seriesEpisodes);
}, [seriesEpisodes]);
// Get available seasons sorted
const seasons = React.useMemo(() => {
- return Object.keys(episodesBySeason)
- .map(Number)
- .sort((a, b) => a - b);
+ return sortBySeasonNumber(episodesBySeason);
}, [episodesBySeason]);
// Update active tab when seasons change or modal opens
@@ -244,49 +446,12 @@ const SeriesModal = ({ series, opened, onClose }) => {
}, [opened]);
const handlePlayEpisode = (episode) => {
- let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
-
- // Add selected provider as query parameter if available
- if (selectedProvider) {
- // Use stream_id for most specific selection, fallback to account_id
- if (selectedProvider.stream_id) {
- streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
- } else {
- streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
- }
- }
-
- if (env_mode === 'dev') {
- streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
- } else {
- streamUrl = `${window.location.origin}${streamUrl}`;
- }
+ const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode);
showVideo(streamUrl, 'vod', episode);
};
- const getEpisodeStreamUrl = (episode) => {
- let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
-
- // Add selected provider as query parameter if available
- if (selectedProvider) {
- // Use stream_id for most specific selection, fallback to account_id
- if (selectedProvider.stream_id) {
- streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
- } else {
- streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
- }
- }
-
- if (env_mode === 'dev') {
- streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
- } else {
- streamUrl = `${window.location.origin}${streamUrl}`;
- }
- return streamUrl;
- };
-
const handleCopyEpisodeLink = async (episode) => {
- const streamUrl = getEpisodeStreamUrl(episode);
+ const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode);
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Episode link copied to clipboard',
@@ -297,13 +462,14 @@ const SeriesModal = ({ series, opened, onClose }) => {
setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id);
};
- // Helper to get embeddable YouTube URL
- const getEmbedUrl = (url) => {
- if (!url) return '';
- // Accepts full YouTube URLs or just IDs
- const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
- const videoId = match ? match[1] : url;
- return `https://www.youtube.com/embed/${videoId}`;
+ const onClickYouTubeTrailer = () => {
+ setTrailerUrl(getYouTubeEmbedUrl(displaySeries.youtube_trailer));
+ setTrailerModalOpened(true);
+ };
+
+ const onChangeSelectedProvider = (value) => {
+ const provider = providers.find((p) => p.id.toString() === value);
+ setSelectedProvider(provider);
};
if (!series) return null;
@@ -320,7 +486,7 @@ const SeriesModal = ({ series, opened, onClose }) => {
size="xl"
centered
>
-
+
{/* Backdrop image as background */}
{displaySeries.backdrop_path &&
displaySeries.backdrop_path.length > 0 && (
@@ -329,203 +495,58 @@ const SeriesModal = ({ series, opened, onClose }) => {
src={displaySeries.backdrop_path[0]}
alt={`${displaySeries.name} backdrop`}
fit="cover"
+ pos="absolute"
+ top={0}
+ left={0}
+ w={'100%'}
+ h={'100%'}
+ bdrs={8}
style={{
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
- height: '100%',
objectFit: 'cover',
zIndex: 0,
- borderRadius: 8,
filter: 'blur(2px) brightness(0.5)',
}}
/>
{/* Overlay for readability */}
>
)}
{/* Modal content above backdrop */}
-
+
{loadingDetails && (
-
+
Loading series details and episodes...
)}
{/* Series poster and basic info */}
-
- {displaySeries.series_image || displaySeries.logo?.url ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- {displaySeries.name}
-
- {/* Original name if different */}
- {displaySeries.o_name &&
- displaySeries.o_name !== displaySeries.name && (
-
- Original: {displaySeries.o_name}
-
- )}
-
-
- {displaySeries.year && (
- {displaySeries.year}
- )}
- {displaySeries.rating && (
- {displaySeries.rating}
- )}
- {displaySeries.age && (
- {displaySeries.age}
- )}
- Series
- {displaySeries.episode_count && (
-
- {displaySeries.episode_count} episodes
-
- )}
- {/* imdb_id and tmdb_id badges */}
- {displaySeries.imdb_id && (
-
- IMDb
-
- )}
- {displaySeries.tmdb_id && (
-
- TMDb
-
- )}
-
-
- {/* Release date */}
- {displaySeries.release_date && (
-
- Release Date:{' '}
- {displaySeries.release_date}
-
- )}
-
- {displaySeries.genre && (
-
- Genre: {displaySeries.genre}
-
- )}
-
- {displaySeries.director && (
-
- Director: {displaySeries.director}
-
- )}
-
- {displaySeries.cast && (
-
- Cast: {displaySeries.cast}
-
- )}
-
- {displaySeries.country && (
-
- Country: {displaySeries.country}
-
- )}
-
- {/* Description */}
- {displaySeries.description && (
-
-
- Description
-
- {displaySeries.description}
-
- )}
-
- {/* Watch Trailer button if available */}
- {displaySeries.youtube_trailer && (
-
- )}
-
-
+
{/* Provider Information */}
Stream Selection
- {loadingProviders && (
-
- )}
+ {loadingProviders && }
{providers.length === 0 &&
!loadingProviders &&
@@ -553,14 +574,9 @@ const SeriesModal = ({ series, opened, onClose }) => {
label: formatStreamLabel(provider),
}))}
value={selectedProvider?.id?.toString() || ''}
- onChange={(value) => {
- const provider = providers.find(
- (p) => p.id.toString() === value
- );
- setSelectedProvider(provider);
- }}
+ onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
- style={{ maxWidth: 350 }}
+ maw={350}
disabled={loadingProviders}
/>
) : null}
@@ -579,69 +595,61 @@ const SeriesModal = ({ series, opened, onClose }) => {
) : seasons.length > 0 ? (
-
+
{seasons.map((season) => (
-
+
Season {season}
-
+
))}
-
+
{seasons.map((season) => (
-
+
-
-
- Ep
- Title
-
- Duration
-
- Date
-
- Action
-
-
-
-
+
+
+ Ep
+ Title
+ Duration
+ Date
+ Action
+
+
+
{episodesBySeason[season]?.map((episode) => (
- handleEpisodeRowClick(episode)}
>
-
+
{episode.episode_number || '?'}
-
-
+
+
{episode.name}
{episode.genre && (
-
+
{episode.genre}
)}
-
-
-
+
+
+
{formatDuration(episode.duration_secs)}
-
-
-
- {episode.air_date
- ? new Date(
- episode.air_date
- ).toLocaleDateString()
- : 'N/A'}
+
+
+
+ {getEpisodeAirdate(episode)}
-
-
+
+
{
-
-
+
+
{expandedEpisode === episode.id && (
-
-
+
-
- {/* Episode Image and Description Row */}
-
- {/* Episode Image */}
- {episode.movie_image && (
-
-
-
- )}
-
- {/* Episode Description */}
-
- {episode.description && (
-
-
- Description
-
-
- {episode.description}
-
-
- )}
-
-
-
- {/* Additional Episode Details */}
-
- {episode.rating && (
-
-
- Rating
-
-
- {episode.rating}
-
-
- )}
- {/* IMDb and TMDb badges for episode */}
- {(episode.imdb_id ||
- displaySeries.tmdb_id) && (
-
-
- Links
-
- {episode.imdb_id && (
-
- IMDb
-
- )}
- {displaySeries.tmdb_id && (
-
- TMDb
-
- )}
-
- )}
-
- {episode.director && (
-
-
- Director
-
-
- {episode.director}
-
-
- )}
-
- {episode.actors && (
-
-
- Cast
-
-
- {episode.actors}
-
-
- )}
-
-
- {/* Technical Details */}
- {(episode.bitrate ||
- episode.video ||
- episode.audio) && (
-
-
- Technical Details
-
-
- {episode.bitrate &&
- episode.bitrate > 0 && (
-
- Bitrate:{' '}
- {episode.bitrate} kbps
-
- )}
- {episode.video &&
- Object.keys(episode.video)
- .length > 0 && (
-
- Video:{' '}
- {episode.video
- .codec_long_name ||
- episode.video.codec_name}
- {episode.video.width &&
- episode.video.height
- ? `, ${episode.video.width}x${episode.video.height}`
- : ''}
-
- )}
- {episode.audio &&
- Object.keys(episode.audio)
- .length > 0 && (
-
- Audio:{' '}
- {episode.audio
- .codec_long_name ||
- episode.audio.codec_name}
- {episode.audio.channels
- ? `, ${episode.audio.channels} channels`
- : ''}
-
- )}
-
-
- )}
-
- {/* Provider Information */}
- {episode.m3u_account && (
-
-
- Provider:
-
-
- {episode.m3u_account.name ||
- episode.m3u_account}
-
-
- )}
-
-
-
+
+
+
)}
))}
-
+
-
+
))}
) : (
-
+
No episodes found for this series.
)}
@@ -909,36 +714,11 @@ const SeriesModal = ({ series, opened, onClose }) => {
{/* YouTube Trailer Modal */}
- setTrailerModalOpened(false)}
- title="Trailer"
- size="xl"
- centered
- >
-
- {trailerUrl && (
-
- )}
-
-
+ trailerUrl={trailerUrl}
+ />
>
);
};
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 435509e0..91aab7b9 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -5,7 +5,6 @@ import {
ListOrdered,
Play,
Database,
- SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
Copy,
@@ -18,7 +17,6 @@ import {
} from 'lucide-react';
import {
Avatar,
- AppShell,
Group,
Stack,
Box,
@@ -26,9 +24,8 @@ import {
UnstyledButton,
TextInput,
ActionIcon,
- Menu,
+ AppShellNavbar,
} from '@mantine/core';
-import { notifications } from '@mantine/notifications';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
@@ -61,7 +58,7 @@ const NavLink = ({ item, isActive, collapsed }) => {
)}
{!collapsed && item.badge && (
-
+
{item.badge}
)}
@@ -150,21 +147,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
});
};
- const onLogout = async () => {
- await logout();
- window.location.reload();
- };
-
return (
-
@@ -172,15 +163,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{
{/* Profile Section */}
{
{/* Version is always shown when sidebar is expanded, regardless of auth status */}
{!collapsed && (
-
+
v{appVersion?.version || '0.0.0'}
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
)}
-
+
);
};
diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx
index 855603c0..df4bc087 100644
--- a/frontend/src/components/SystemEvents.jsx
+++ b/frontend/src/components/SystemEvents.jsx
@@ -19,11 +19,9 @@ import {
Download,
Gauge,
HardDriveDownload,
- List,
LogIn,
LogOut,
RefreshCw,
- Shield,
ShieldAlert,
SquareX,
Timer,
@@ -31,25 +29,159 @@ import {
Video,
XCircle,
} from 'lucide-react';
-import dayjs from 'dayjs';
import API from '../api';
import useLocalStorage from '../hooks/useLocalStorage';
+import { format } from '../utils/dateTimeUtils.js';
+
+const getEventIcon = (eventType) => {
+ switch (eventType) {
+ case 'channel_start':
+ return ;
+ case 'channel_stop':
+ return ;
+ case 'channel_reconnect':
+ return ;
+ case 'channel_buffering':
+ return ;
+ case 'channel_failover':
+ return ;
+ case 'client_connect':
+ return ;
+ case 'client_disconnect':
+ return ;
+ case 'recording_start':
+ return ;
+ case 'recording_end':
+ return ;
+ case 'stream_switch':
+ return ;
+ case 'm3u_refresh':
+ return ;
+ case 'm3u_download':
+ return ;
+ case 'epg_refresh':
+ return ;
+ case 'epg_download':
+ return ;
+ case 'login_success':
+ return ;
+ case 'login_failed':
+ return ;
+ case 'logout':
+ return ;
+ case 'm3u_blocked':
+ return ;
+ case 'epg_blocked':
+ return ;
+ default:
+ return ;
+ }
+};
+
+const getEventColor = (eventType) => {
+ switch (eventType) {
+ case 'channel_start':
+ case 'client_connect':
+ case 'recording_start':
+ case 'login_success':
+ return 'green';
+ case 'channel_reconnect':
+ return 'yellow';
+ case 'channel_stop':
+ case 'client_disconnect':
+ case 'recording_end':
+ case 'logout':
+ return 'gray';
+ case 'channel_buffering':
+ return 'yellow';
+ case 'channel_failover':
+ case 'channel_error':
+ return 'orange';
+ case 'stream_switch':
+ return 'blue';
+ case 'm3u_refresh':
+ case 'epg_refresh':
+ return 'cyan';
+ case 'm3u_download':
+ case 'epg_download':
+ return 'teal';
+ case 'login_failed':
+ case 'm3u_blocked':
+ case 'epg_blocked':
+ return 'red';
+ default:
+ return 'gray';
+ }
+};
+
+const getSystemEvents = (eventsLimit, offset) => {
+ return API.getSystemEvents(eventsLimit, offset);
+}
+
+const Event = ({ event }) => {
+ const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
+ const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
+
+ return (
+
+
+
+
+ {getEventIcon(event.event_type)}
+
+
+
+
+ {event.event_type_display || event.event_type}
+
+ {event.channel_name && (
+
+ {event.channel_name}
+
+ )}
+
+ {event.details &&
+ Object.keys(event.details).length > 0 && (
+
+ {Object.entries(event.details)
+ .filter(([key]) =>
+ !['stream_url', 'new_url'].includes(key))
+ .map(([key, value]) => `${key}: ${value}`)
+ .join(', ')}
+
+ )}
+
+
+
+ {format(event.timestamp, `${dateFormat} HH:mm:ss`)}
+
+
+
+ );
+};
const SystemEvents = () => {
const [events, setEvents] = useState([]);
const [totalEvents, setTotalEvents] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
- const { ref: cardRef, width: cardWidth } = useElementSize();
- const isNarrow = cardWidth < 650;
const [isLoading, setIsLoading] = useState(false);
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
- const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
+ const [currentPage, setCurrentPage] = useState(1);
+
const [eventsRefreshInterval, setEventsRefreshInterval] = useLocalStorage(
'events-refresh-interval',
0
);
const [eventsLimit, setEventsLimit] = useLocalStorage('events-limit', 100);
- const [currentPage, setCurrentPage] = useState(1);
+
+ const { ref: cardRef, width: cardWidth } = useElementSize();
+ const isNarrow = cardWidth < 650;
// Calculate offset based on current page and limit
const offset = (currentPage - 1) * eventsLimit;
@@ -58,7 +190,7 @@ const SystemEvents = () => {
const fetchEvents = useCallback(async () => {
try {
setIsLoading(true);
- const response = await API.getSystemEvents(eventsLimit, offset);
+ const response = await getSystemEvents(eventsLimit, offset);
if (response && response.events) {
setEvents(response.events);
setTotalEvents(response.total || 0);
@@ -86,87 +218,6 @@ const SystemEvents = () => {
setCurrentPage(1);
}, [eventsLimit]);
- const getEventIcon = (eventType) => {
- switch (eventType) {
- case 'channel_start':
- return ;
- case 'channel_stop':
- return ;
- case 'channel_reconnect':
- return ;
- case 'channel_buffering':
- return ;
- case 'channel_failover':
- return ;
- case 'client_connect':
- return ;
- case 'client_disconnect':
- return ;
- case 'recording_start':
- return ;
- case 'recording_end':
- return ;
- case 'stream_switch':
- return ;
- case 'm3u_refresh':
- return ;
- case 'm3u_download':
- return ;
- case 'epg_refresh':
- return ;
- case 'epg_download':
- return ;
- case 'login_success':
- return ;
- case 'login_failed':
- return ;
- case 'logout':
- return ;
- case 'm3u_blocked':
- return ;
- case 'epg_blocked':
- return ;
- default:
- return ;
- }
- };
-
- const getEventColor = (eventType) => {
- switch (eventType) {
- case 'channel_start':
- case 'client_connect':
- case 'recording_start':
- case 'login_success':
- return 'green';
- case 'channel_reconnect':
- return 'yellow';
- case 'channel_stop':
- case 'client_disconnect':
- case 'recording_end':
- case 'logout':
- return 'gray';
- case 'channel_buffering':
- return 'yellow';
- case 'channel_failover':
- case 'channel_error':
- return 'orange';
- case 'stream_switch':
- return 'blue';
- case 'm3u_refresh':
- case 'epg_refresh':
- return 'cyan';
- case 'm3u_download':
- case 'epg_download':
- return 'teal';
- case 'login_failed':
- case 'm3u_blocked':
- case 'epg_blocked':
- return 'red';
- default:
- return 'gray';
- }
- };
-
return (
{
padding="sm"
radius="md"
withBorder
+ color="#fff"
+ w={'100%'}
+ maw={isExpanded ? '100%' : 800}
+ ml="auto"
+ mr="auto"
style={{
- color: '#fff',
backgroundColor: '#27272A',
- width: '100%',
- maxWidth: isExpanded ? '100%' : '800px',
- marginLeft: 'auto',
- marginRight: 'auto',
transition: 'max-width 0.3s ease',
}}
>
@@ -200,7 +251,7 @@ const SystemEvents = () => {
min={10}
max={1000}
step={10}
- style={{ width: 130 }}
+ w={130}
/>
) : (
events.map((event) => (
-
-
-
-
- {getEventIcon(event.event_type)}
-
-
-
-
- {event.event_type_display || event.event_type}
-
- {event.channel_name && (
-
- {event.channel_name}
-
- )}
-
- {event.details &&
- Object.keys(event.details).length > 0 && (
-
- {Object.entries(event.details)
- .filter(
- ([key]) =>
- !['stream_url', 'new_url'].includes(key)
- )
- .map(([key, value]) => `${key}: ${value}`)
- .join(', ')}
-
- )}
-
-
-
- {dayjs(event.timestamp).format(`${dateFormat} HH:mm:ss`)}
-
-
-
+
))
)}
diff --git a/frontend/src/components/VODModal.jsx b/frontend/src/components/VODModal.jsx
index f0ff7fec..0c6f7fdf 100644
--- a/frontend/src/components/VODModal.jsx
+++ b/frontend/src/components/VODModal.jsx
@@ -1,165 +1,233 @@
-import React, { useState, useEffect } from 'react';
-import {
- Box,
- Button,
- Flex,
- Group,
- Image,
- Text,
- Title,
- Select,
- Badge,
- Loader,
- Stack,
- Modal,
-} from '@mantine/core';
-import { Play, Copy } from 'lucide-react';
-import { notifications } from '@mantine/notifications';
+import React, { useEffect, useState } from 'react';
+import { Badge, Box, Button, Flex, Group, Image, Loader, Modal, Select, Stack, Text, Title, } from '@mantine/core';
+import { Copy, Play } from 'lucide-react';
import { copyToClipboard } from '../utils';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
+import {
+ formatDuration,
+ formatStreamLabel,
+ getYouTubeEmbedUrl,
+ imdbUrl,
+ tmdbUrl
+} from '../utils/components/SeriesModalUtils.js';
+import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
+import {
+ formatAudioDetails,
+ formatVideoDetails,
+ getMovieStreamUrl,
+ getTechnicalDetails,
+} from '../utils/components/VODModalUtils.js';
-const imdbUrl = (imdb_id) =>
- imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
-const tmdbUrl = (tmdb_id, type = 'movie') =>
- tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
-const formatDuration = (seconds) => {
- if (!seconds) return '';
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- const secs = seconds % 60;
- return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
-};
+const Movie = ({
+ onClickYouTubeTrailer,
+ hasMultipleProviders,
+ selectedProvider,
+ detailedVOD,
+ vod
+}) => {
+ const showVideo = useVideoStore((s) => s.showVideo);
+ const env_mode = useSettingsStore((s) => s.environment.env_mode);
-const formatStreamLabel = (relation) => {
- // Create a label for the stream that includes provider name and stream-specific info
- const provider = relation.m3u_account.name;
- const streamId = relation.stream_id;
+ const displayVOD = detailedVOD || vod;
- // Try to extract quality info - prioritizing the new quality_info field from backend
- let qualityInfo = '';
+ const getStreamUrl = () => {
+ if (!displayVOD) return null;
- // 1. Check the new quality_info field from backend (PRIMARY)
- if (relation.quality_info) {
- if (relation.quality_info.quality) {
- qualityInfo = ` - ${relation.quality_info.quality}`;
- } else if (relation.quality_info.resolution) {
- qualityInfo = ` - ${relation.quality_info.resolution}`;
- } else if (relation.quality_info.bitrate) {
- qualityInfo = ` - ${relation.quality_info.bitrate}`;
- }
- }
-
- // 2. Fallback: Check custom_properties detailed info structure
- if (qualityInfo === '' && relation.custom_properties) {
- const props = relation.custom_properties;
-
- // Check detailed_info structure (where the real data is!)
- if (qualityInfo === '' && props.detailed_info) {
- const detailedInfo = props.detailed_info;
-
- // Extract from video resolution
- if (
- detailedInfo.video &&
- detailedInfo.video.width &&
- detailedInfo.video.height
- ) {
- const width = detailedInfo.video.width;
- const height = detailedInfo.video.height;
-
- // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
- if (width >= 3840) {
- qualityInfo = ' - 4K';
- } else if (width >= 1920) {
- qualityInfo = ' - 1080p';
- } else if (width >= 1280) {
- qualityInfo = ' - 720p';
- } else if (width >= 854) {
- qualityInfo = ' - 480p';
- } else {
- qualityInfo = ` - ${width}x${height}`;
- }
- }
-
- // Extract from movie name in detailed_info
- if (qualityInfo === '' && detailedInfo.name) {
- const name = detailedInfo.name;
- if (name.includes('4K') || name.includes('2160p')) {
- qualityInfo = ' - 4K';
- } else if (name.includes('1080p') || name.includes('FHD')) {
- qualityInfo = ' - 1080p';
- } else if (name.includes('720p') || name.includes('HD')) {
- qualityInfo = ' - 720p';
- } else if (name.includes('480p')) {
- qualityInfo = ' - 480p';
- }
- }
- }
- }
-
- // 3. Final fallback: Check stream name for quality markers
- if (qualityInfo === '' && relation.stream_name) {
- const streamName = relation.stream_name;
- if (streamName.includes('4K') || streamName.includes('2160p')) {
- qualityInfo = ' - 4K';
- } else if (streamName.includes('1080p') || streamName.includes('FHD')) {
- qualityInfo = ' - 1080p';
- } else if (streamName.includes('720p') || streamName.includes('HD')) {
- qualityInfo = ' - 720p';
- } else if (streamName.includes('480p')) {
- qualityInfo = ' - 480p';
- }
- }
-
- return `${provider}${qualityInfo}${streamId ? ` (Stream ${streamId})` : ''}`;
-};
-
-const getTechnicalDetails = (selectedProvider, defaultVOD) => {
- let source = defaultVOD; // Default fallback
-
- // If a provider is selected, try to get technical details from various locations
- if (selectedProvider) {
- // 1. First try the movie/episode relation content
- const content = selectedProvider.movie || selectedProvider.episode;
-
- if (content && (content.bitrate || content.video || content.audio)) {
- source = content;
- }
- // 2. Try technical details directly on the relation object
- else if (
- selectedProvider.bitrate ||
- selectedProvider.video ||
- selectedProvider.audio
- ) {
- source = selectedProvider;
- }
- // 3. Try to extract from custom_properties detailed_info (where quality data is stored)
- else if (selectedProvider.custom_properties?.detailed_info) {
- const detailedInfo = selectedProvider.custom_properties.detailed_info;
-
- // Create a synthetic source from detailed_info
- const syntheticSource = {
- bitrate: detailedInfo.bitrate || null,
- video: detailedInfo.video || null,
- audio: detailedInfo.audio || null,
- };
-
- if (
- syntheticSource.bitrate ||
- syntheticSource.video ||
- syntheticSource.audio
- ) {
- source = syntheticSource;
- }
- }
- }
-
- return {
- bitrate: source?.bitrate,
- video: source?.video,
- audio: source?.audio,
+ return getMovieStreamUrl(vod, selectedProvider, env_mode);
};
+
+ const handlePlayVOD = () => {
+ const streamUrl = getStreamUrl();
+ if (!streamUrl) return;
+ showVideo(streamUrl, 'vod', displayVOD);
+ };
+
+ const handleCopyLink = async () => {
+ const streamUrl = getStreamUrl();
+ if (!streamUrl) return;
+ await copyToClipboard(streamUrl, {
+ successTitle: 'Link Copied!',
+ successMessage: 'Stream link copied to clipboard',
+ });
+ };
+
+ return (
+
+ {displayVOD.name}
+
+ {/* Original name if different */}
+ {displayVOD.o_name &&
+ displayVOD.o_name !== displayVOD.name && (
+
+ Original: {displayVOD.o_name}
+
+ )}
+
+
+ {displayVOD.year && (
+ {displayVOD.year}
+ )}
+ {displayVOD.duration_secs && (
+
+ {formatDuration(displayVOD.duration_secs)}
+
+ )}
+ {displayVOD.rating && (
+ {displayVOD.rating}
+ )}
+ {displayVOD.age && (
+ {displayVOD.age}
+ )}
+ Movie
+ {/* imdb_id and tmdb_id badges */}
+ {displayVOD.imdb_id && (
+
+ IMDb
+
+ )}
+ {displayVOD.tmdb_id && (
+
+ TMDb
+
+ )}
+
+
+ {/* Release date */}
+ {displayVOD.release_date && (
+
+ Release Date: {displayVOD.release_date}
+
+ )}
+
+ {displayVOD.genre && (
+
+ Genre: {displayVOD.genre}
+
+ )}
+
+ {displayVOD.director && (
+
+ Director: {displayVOD.director}
+
+ )}
+
+ {displayVOD.actors && (
+
+ Cast: {displayVOD.actors}
+
+ )}
+
+ {displayVOD.country && (
+
+ Country: {displayVOD.country}
+
+ )}
+
+ {/* Description */}
+ {displayVOD.description && (
+
+
+ Description
+
+ {displayVOD.description}
+
+ )}
+
+ {/* Play and Watch Trailer buttons */}
+
+ }
+ variant="filled"
+ color="blue"
+ size="sm"
+ onClick={handlePlayVOD}
+ disabled={hasMultipleProviders && !selectedProvider}
+ style={{ alignSelf: 'flex-start' }}
+ >
+ Play Movie
+
+ {displayVOD.youtube_trailer && (
+
+ )}
+ }
+ variant="outline"
+ color="gray"
+ size="sm"
+ onClick={handleCopyLink}
+ style={{ alignSelf: 'flex-start' }}
+ >
+ Copy Link
+
+
+
+ );
+};
+
+const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
+ const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
+ const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
+
+ if (!hasDetails) return null;
+
+ const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
+ const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
+
+ return (
+
+
+ Technical Details:
+ {selectedProvider && (
+
+ (from {selectedProvider.m3u_account.name}
+ {selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
+
+ )}
+
+
+ {techDetails.bitrate && techDetails.bitrate > 0 && (
+
+ Bitrate: {techDetails.bitrate} kbps
+
+ )}
+
+ {hasVideo && (
+
+ Video: {formatVideoDetails(techDetails.video)}
+
+ )}
+
+ {hasAudio && (
+
+ Audio: {formatAudioDetails(techDetails.audio)}
+
+ )}
+
+ );
};
const VODModal = ({ vod, opened, onClose }) => {
@@ -170,9 +238,8 @@ const VODModal = ({ vod, opened, onClose }) => {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [loadingProviders, setLoadingProviders] = useState(false);
+
const { fetchMovieDetailsFromProvider, fetchMovieProviders } = useVODStore();
- const showVideo = useVideoStore((s) => s.showVideo);
- const env_mode = useSettingsStore((s) => s.environment.env_mode);
useEffect(() => {
if (opened && vod) {
@@ -234,54 +301,17 @@ const VODModal = ({ vod, opened, onClose }) => {
}
}, [opened]);
- const getStreamUrl = () => {
- const vodToPlay = detailedVOD || vod;
- if (!vodToPlay) return null;
+ const onClickYouTubeTrailer = () => {
+ setTrailerUrl(
+ getYouTubeEmbedUrl(displayVOD.youtube_trailer)
+ );
+ setTrailerModalOpened(true);
+ }
- let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
-
- // Add selected provider as query parameter if available
- if (selectedProvider) {
- // Use stream_id for most specific selection, fallback to account_id
- if (selectedProvider.stream_id) {
- streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
- } else {
- streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
- }
- }
-
- if (env_mode === 'dev') {
- streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
- } else {
- streamUrl = `${window.location.origin}${streamUrl}`;
- }
- return streamUrl;
- };
-
- const handlePlayVOD = () => {
- const streamUrl = getStreamUrl();
- if (!streamUrl) return;
- const vodToPlay = detailedVOD || vod;
- showVideo(streamUrl, 'vod', vodToPlay);
- };
-
- const handleCopyLink = async () => {
- const streamUrl = getStreamUrl();
- if (!streamUrl) return;
- await copyToClipboard(streamUrl, {
- successTitle: 'Link Copied!',
- successMessage: 'Stream link copied to clipboard',
- });
- };
-
- // Helper to get embeddable YouTube URL
- const getEmbedUrl = (url) => {
- if (!url) return '';
- // Accepts full YouTube URLs or just IDs
- const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
- const videoId = match ? match[1] : url;
- return `https://www.youtube.com/embed/${videoId}`;
- };
+ const onChangeSelectedProvider = (value) => {
+ const provider = providers.find((p) => p.id.toString() === value);
+ setSelectedProvider(provider);
+ }
if (!vod) return null;
@@ -297,7 +327,7 @@ const VODModal = ({ vod, opened, onClose }) => {
size="xl"
centered
>
-
+
{/* Backdrop image as background */}
{displayVOD.backdrop_path && displayVOD.backdrop_path.length > 0 && (
<>
@@ -305,41 +335,41 @@ const VODModal = ({ vod, opened, onClose }) => {
src={displayVOD.backdrop_path[0]}
alt={`${displayVOD.name} backdrop`}
fit="cover"
+ pos="absolute"
+ top={0}
+ left={0}
+ w="100%"
+ h="100%"
+ bdrs={8}
style={{
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
- height: '100%',
objectFit: 'cover',
zIndex: 0,
- borderRadius: 8,
filter: 'blur(2px) brightness(0.5)',
}}
/>
{/* Overlay for readability */}
>
)}
{/* Modal content above backdrop */}
-
+
{loadingDetails && (
-
+
Loading additional details...
@@ -356,19 +386,19 @@ const VODModal = ({ vod, opened, onClose }) => {
height={300}
alt={displayVOD.name}
fit="contain"
- style={{ borderRadius: '8px' }}
+ bdrs={8}
/>
) : (
@@ -376,158 +406,23 @@ const VODModal = ({ vod, opened, onClose }) => {
)}
-
- {displayVOD.name}
-
- {/* Original name if different */}
- {displayVOD.o_name &&
- displayVOD.o_name !== displayVOD.name && (
-
- Original: {displayVOD.o_name}
-
- )}
-
-
- {displayVOD.year && (
- {displayVOD.year}
- )}
- {displayVOD.duration_secs && (
-
- {formatDuration(displayVOD.duration_secs)}
-
- )}
- {displayVOD.rating && (
- {displayVOD.rating}
- )}
- {displayVOD.age && (
- {displayVOD.age}
- )}
- Movie
- {/* imdb_id and tmdb_id badges */}
- {displayVOD.imdb_id && (
-
- IMDb
-
- )}
- {displayVOD.tmdb_id && (
-
- TMDb
-
- )}
-
-
- {/* Release date */}
- {displayVOD.release_date && (
-
- Release Date: {displayVOD.release_date}
-
- )}
-
- {displayVOD.genre && (
-
- Genre: {displayVOD.genre}
-
- )}
-
- {displayVOD.director && (
-
- Director: {displayVOD.director}
-
- )}
-
- {displayVOD.actors && (
-
- Cast: {displayVOD.actors}
-
- )}
-
- {displayVOD.country && (
-
- Country: {displayVOD.country}
-
- )}
-
- {/* Description */}
- {displayVOD.description && (
-
-
- Description
-
- {displayVOD.description}
-
- )}
-
- {/* Play and Watch Trailer buttons */}
-
- }
- variant="filled"
- color="blue"
- size="sm"
- onClick={handlePlayVOD}
- disabled={providers.length > 0 && !selectedProvider}
- style={{ alignSelf: 'flex-start' }}
- >
- Play Movie
-
- {displayVOD.youtube_trailer && (
-
- )}
- }
- variant="outline"
- color="gray"
- size="sm"
- onClick={handleCopyLink}
- style={{ alignSelf: 'flex-start' }}
- >
- Copy Link
-
-
-
+ 0}
+ selectedProvider={selectedProvider}
+ onClickYouTubeTrailer={onClickYouTubeTrailer}
+ />
{/* Provider Information & Play Button Row */}
{/* Provider Selection */}
{providers.length > 0 && (
-
+
Stream Selection
- {loadingProviders && (
-
- )}
+ {loadingProviders && }
{providers.length === 1 ? (
@@ -542,14 +437,9 @@ const VODModal = ({ vod, opened, onClose }) => {
label: formatStreamLabel(provider),
}))}
value={selectedProvider?.id?.toString() || ''}
- onChange={(value) => {
- const provider = providers.find(
- (p) => p.id.toString() === value
- );
- setSelectedProvider(provider);
- }}
+ onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
- style={{ minWidth: 250 }}
+ miw={250}
disabled={loadingProviders}
/>
)}
@@ -576,135 +466,21 @@ const VODModal = ({ vod, opened, onClose }) => {
{/* Technical Details */}
- {(() => {
- const techDetails = getTechnicalDetails(
- selectedProvider,
- displayVOD
- );
- const hasDetails =
- techDetails.bitrate || techDetails.video || techDetails.audio;
-
- return (
- hasDetails && (
-
-
- Technical Details:
- {selectedProvider && (
-
- (from {selectedProvider.m3u_account.name}
- {selectedProvider.stream_id &&
- ` - Stream ${selectedProvider.stream_id}`}
- )
-
- )}
-
- {techDetails.bitrate && techDetails.bitrate > 0 && (
-
- Bitrate: {techDetails.bitrate} kbps
-
- )}
- {techDetails.video &&
- Object.keys(techDetails.video).length > 0 && (
-
- Video:{' '}
- {techDetails.video.codec_long_name &&
- techDetails.video.codec_long_name !== 'unknown'
- ? techDetails.video.codec_long_name
- : techDetails.video.codec_name}
- {techDetails.video.profile
- ? ` (${techDetails.video.profile})`
- : ''}
- {techDetails.video.width && techDetails.video.height
- ? `, ${techDetails.video.width}x${techDetails.video.height}`
- : ''}
- {techDetails.video.display_aspect_ratio
- ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}`
- : ''}
- {techDetails.video.bit_rate
- ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps`
- : ''}
- {techDetails.video.r_frame_rate
- ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
- : ''}
- {techDetails.video.tags?.encoder
- ? `, Encoder: ${techDetails.video.tags.encoder}`
- : ''}
-
- )}
- {techDetails.audio &&
- Object.keys(techDetails.audio).length > 0 && (
-
- Audio:{' '}
- {techDetails.audio.codec_long_name &&
- techDetails.audio.codec_long_name !== 'unknown'
- ? techDetails.audio.codec_long_name
- : techDetails.audio.codec_name}
- {techDetails.audio.profile
- ? ` (${techDetails.audio.profile})`
- : ''}
- {techDetails.audio.channel_layout
- ? `, Channels: ${techDetails.audio.channel_layout}`
- : techDetails.audio.channels
- ? `, Channels: ${techDetails.audio.channels}`
- : ''}
- {techDetails.audio.sample_rate
- ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
- : ''}
- {techDetails.audio.bit_rate
- ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
- : ''}
- {techDetails.audio.tags?.handler_name
- ? `, Handler: ${techDetails.audio.tags.handler_name}`
- : ''}
-
- )}
-
- )
- );
- })()}
- {/* YouTube trailer if available */}
+
+
{/* YouTube Trailer Modal */}
- setTrailerModalOpened(false)}
- title="Trailer"
- size="xl"
- centered
- withCloseButton
- >
-
- {trailerUrl && (
-
- )}
-
-
+ trailerUrl={trailerUrl}
+ />
>
);
};
From f3304bd976aab45780920935cc06544d23480617 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Mon, 2 Mar 2026 23:22:03 -0800
Subject: [PATCH 05/15] Updated tests after sync
---
.../src/components/__tests__/Field.test.jsx | 422 +++++++++++++++-
.../components/__tests__/GuideRow.test.jsx | 468 ++++++++++++++++--
.../src/components/__tests__/Sidebar.test.jsx | 191 ++++++-
3 files changed, 1024 insertions(+), 57 deletions(-)
diff --git a/frontend/src/components/__tests__/Field.test.jsx b/frontend/src/components/__tests__/Field.test.jsx
index 3f57356d..092871f1 100644
--- a/frontend/src/components/__tests__/Field.test.jsx
+++ b/frontend/src/components/__tests__/Field.test.jsx
@@ -5,20 +5,21 @@ import { Field } from '../Field';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
- TextInput: ({ label, description, value, onChange }) => (
+ TextInput: ({ label, description, value, onChange, type, placeholder }) => (
{description &&
{description}
}
),
- NumberInput: ({ label, description, value, onChange }) => (
+ NumberInput: ({ label, description, value, onChange, placeholder }) => (
{
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
+ placeholder={placeholder}
+ aria-describedby={description}
+ />
+ {description &&
{description}
}
+
+ ),
+ Textarea: ({ label, description, value, onChange, placeholder }) => (
+
+
+
{description &&
{description}
}
@@ -44,13 +59,14 @@ vi.mock('@mantine/core', async () => {
{description &&
{description}
}
),
- Select: ({ label, description, value, data, onChange }) => (
+ Select: ({ label, description, value, data, onChange, placeholder }) => (
),
+ Text: ({ children, fw, size, c }) => (
+
+ {children}
+
+ ),
};
});
@@ -371,6 +392,399 @@ describe('Field', () => {
});
});
+ describe('Textarea (text type)', () => {
+ it('should render Textarea for text type', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ help_text: 'Enter your biography',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toBeInTheDocument();
+ expect(screen.getByText('Enter your biography')).toBeInTheDocument();
+ });
+
+ it('should use provided value', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveValue('My bio');
+ });
+
+ it('should use default value when value is null', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ default: 'Default bio',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveValue('Default bio');
+ });
+
+ it('should call onChange with field id and value', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ default: '',
+ };
+
+ render();
+
+ fireEvent.change(screen.getByLabelText('Biography'), {
+ target: { value: 'New bio text' },
+ });
+
+ expect(mockOnChange).toHaveBeenCalledWith('bio', 'New bio text');
+ });
+
+ it('should render with placeholder', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ placeholder: 'Enter your bio here...',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveAttribute(
+ 'placeholder',
+ 'Enter your bio here...'
+ );
+ });
+ });
+
+ describe('Info type', () => {
+ it('should render info with label and description', () => {
+ const field = {
+ id: 'info1',
+ type: 'info',
+ label: 'Important Information',
+ description: 'This is important info',
+ };
+
+ render();
+
+ expect(screen.getByText('Important Information')).toBeInTheDocument();
+ expect(screen.getByText('This is important info')).toBeInTheDocument();
+ });
+
+ it('should render info with only description', () => {
+ const field = {
+ id: 'info2',
+ type: 'info',
+ description: 'Just a description',
+ };
+
+ render();
+
+ expect(screen.getByText('Just a description')).toBeInTheDocument();
+ });
+
+ it('should render info with only label', () => {
+ const field = {
+ id: 'info3',
+ type: 'info',
+ label: 'Just a label',
+ };
+
+ render();
+
+ expect(screen.getByText('Just a label')).toBeInTheDocument();
+ });
+
+ it('should prioritize help_text over description', () => {
+ const field = {
+ id: 'info4',
+ type: 'info',
+ label: 'Title',
+ help_text: 'Help text takes priority',
+ description: 'This should not appear',
+ };
+
+ render();
+
+ expect(screen.getByText('Help text takes priority')).toBeInTheDocument();
+ expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
+ });
+
+ it('should use field.value if no help_text or description', () => {
+ const field = {
+ id: 'info5',
+ type: 'info',
+ label: 'Title',
+ value: 'Value text',
+ };
+
+ render();
+
+ expect(screen.getByText('Value text')).toBeInTheDocument();
+ });
+
+ it('should not call onChange for info type', () => {
+ const field = {
+ id: 'info6',
+ type: 'info',
+ label: 'Read-only Info',
+ description: 'Cannot be changed',
+ };
+
+ render();
+
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Password input type', () => {
+ it('should render password input when input_type is password', () => {
+ const field = {
+ id: 'password',
+ type: 'string',
+ label: 'Password',
+ input_type: 'password',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
+ });
+
+ it('should render text input when input_type is not password', () => {
+ const field = {
+ id: 'username',
+ type: 'string',
+ label: 'Username',
+ input_type: 'text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Username')).toHaveAttribute('type', 'text');
+ });
+
+ it('should default to text input when input_type is undefined', () => {
+ const field = {
+ id: 'email',
+ type: 'string',
+ label: 'Email',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'text');
+ });
+ });
+
+ describe('Description priority', () => {
+ it('should prioritize help_text over description', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ help_text: 'Help text',
+ description: 'Description text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByText('Help text')).toBeInTheDocument();
+ expect(screen.queryByText('Description text')).not.toBeInTheDocument();
+ });
+
+ it('should use description when help_text is not provided', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ description: 'Description text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByText('Description text')).toBeInTheDocument();
+ });
+
+ it('should use field.value when neither help_text nor description provided', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ value: 'Value text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByText('Value text')).toBeInTheDocument();
+ });
+
+ it('should not show description when all are undefined', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Test')).toBeInTheDocument();
+ // No description should be present
+ });
+ });
+
+ describe('Placeholder handling', () => {
+ it('should render placeholder for TextInput', () => {
+ const field = {
+ id: 'name',
+ type: 'string',
+ label: 'Name',
+ placeholder: 'Enter your name',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Name')).toHaveAttribute(
+ 'placeholder',
+ 'Enter your name'
+ );
+ });
+
+ it('should render placeholder for NumberInput', () => {
+ const field = {
+ id: 'age',
+ type: 'number',
+ label: 'Age',
+ placeholder: 'Enter your age',
+ default: 0,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Age')).toHaveAttribute(
+ 'placeholder',
+ 'Enter your age'
+ );
+ });
+
+ it('should render placeholder for Select', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ placeholder: 'Select a country',
+ default: '',
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'ca', label: 'Canada' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Country')).toHaveAttribute(
+ 'placeholder',
+ 'Select a country'
+ );
+ });
+
+ it('should render placeholder for Textarea', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ placeholder: 'Tell us about yourself',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveAttribute(
+ 'placeholder',
+ 'Tell us about yourself'
+ );
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle empty string default value', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Test')).toHaveValue('');
+ });
+
+ it('should handle 0 as valid number value', () => {
+ const field = {
+ id: 'count',
+ type: 'number',
+ label: 'Count',
+ default: 10,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Count')).toHaveValue(0);
+ });
+
+ it('should handle false as valid boolean value', () => {
+ const field = {
+ id: 'enabled',
+ type: 'boolean',
+ label: 'Enabled',
+ default: true,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Enabled')).not.toBeChecked();
+ });
+
+ it('should handle empty string as valid select value', () => {
+ const field = {
+ id: 'status',
+ type: 'select',
+ label: 'Status',
+ default: 'active',
+ options: [
+ { value: '', label: 'None' },
+ { value: 'active', label: 'Active' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Status')).toHaveValue('');
+ });
+ });
+
describe('Default fallback', () => {
it('should render TextInput for unknown type', () => {
const field = {
diff --git a/frontend/src/components/__tests__/GuideRow.test.jsx b/frontend/src/components/__tests__/GuideRow.test.jsx
index 3f103305..8dc18eaf 100644
--- a/frontend/src/components/__tests__/GuideRow.test.jsx
+++ b/frontend/src/components/__tests__/GuideRow.test.jsx
@@ -28,6 +28,18 @@ vi.mock('@mantine/core', async () => {
};
});
+// Helper function to create programs at specific times
+const createProgramAtTime = (id, startHour, durationMinutes) => {
+ const timelineStart = new Date('2024-01-01T00:00:00Z');
+ const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
+ return {
+ id,
+ title: `Program ${id}`,
+ startMs,
+ endMs: startMs + durationMinutes * 60 * 1000,
+ };
+};
+
describe('GuideRow', () => {
const mockChannel = {
id: 'channel-1',
@@ -64,6 +76,9 @@ describe('GuideRow', () => {
)),
handleLogoClick: vi.fn(),
contentWidth: 1920,
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: 1920,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const mockStyle = {
@@ -160,24 +175,26 @@ describe('GuideRow', () => {
describe('Programs Rendering', () => {
it('should render programs when channel has programs', () => {
- render(
-
- );
+ // Create program at hour 0 (definitely within viewport at scrollLeft 0)
+ const visibleProgram = createProgramAtTime('program-1', 0, 60);
- expect(screen.getByTestId(`program-${mockProgram.id}`)).toBeInTheDocument();
- expect(screen.getByText('Test Program')).toBeInTheDocument();
- expect(mockData.renderProgram).toHaveBeenCalledWith(
- mockProgram,
- undefined,
- mockChannel
- );
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, [visibleProgram]]]),
+ };
+
+ render();
+
+ expect(screen.getByTestId('program-program-1')).toBeInTheDocument();
+ expect(mockData.renderProgram).toHaveBeenCalledWith(visibleProgram, undefined, mockChannel);
});
it('should render multiple programs', () => {
const programs = [
- mockProgram,
- { ...mockProgram, id: 'program-2', title: 'Another Program' },
+ createProgramAtTime('prog-1', 0, 60),
+ createProgramAtTime('prog-2', 1, 30),
];
+
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
@@ -185,9 +202,8 @@ describe('GuideRow', () => {
render();
- expect(screen.getByText('Test Program')).toBeInTheDocument();
- expect(screen.getByText('Another Program')).toBeInTheDocument();
- expect(mockData.renderProgram).toHaveBeenCalledTimes(2);
+ expect(screen.getByTestId('program-prog-1')).toBeInTheDocument();
+ expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
});
it('should render placeholder when channel has no programs', () => {
@@ -199,7 +215,7 @@ describe('GuideRow', () => {
render();
const placeholders = screen.getAllByText('No program data');
- expect(placeholders.length).toBe(Math.ceil(24 / 2));
+ expect(placeholders.length).toBeGreaterThan(0);
});
it('should render placeholder when programsByChannelId does not contain channel', () => {
@@ -211,7 +227,7 @@ describe('GuideRow', () => {
render();
const placeholders = screen.getAllByText('No program data');
- expect(placeholders.length).toBe(Math.ceil(24 / 2));
+ expect(placeholders.length).toBeGreaterThan(0);
});
it('should position placeholder programs correctly', () => {
@@ -252,12 +268,10 @@ describe('GuideRow', () => {
});
it('should show play icon on hover', () => {
- const data = {
- ...mockData,
- hoveredChannelId: mockChannel.id,
- };
+ render();
- render();
+ const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
+ fireEvent.mouseEnter(logo);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
@@ -269,28 +283,6 @@ describe('GuideRow', () => {
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
-
- it('should call setHoveredChannelId on mouse enter', () => {
- render(
-
- );
-
- const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
- fireEvent.mouseEnter(logo);
-
- expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(mockChannel.id);
- });
-
- it('should call setHoveredChannelId with null on mouse leave', () => {
- render(
-
- );
-
- const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
- fireEvent.mouseLeave(logo);
-
- expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(null);
- });
});
describe('Layout and Styling', () => {
@@ -332,4 +324,392 @@ describe('GuideRow', () => {
expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
});
});
+
+ describe('Horizontal Viewport Culling', () => {
+ it('should only render programs visible in viewport', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60), // Hour 0
+ createProgramAtTime('prog-2', 6, 60), // Hour 6
+ createProgramAtTime('prog-3', 12, 60), // Hour 12
+ createProgramAtTime('prog-4', 18, 60), // Hour 18
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 6 }, // Scroll to hour 6
+ viewportWidth: HOUR_WIDTH * 4, // Show 4 hours
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Program at hour 6 should be visible
+ expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
+
+ // Programs outside viewport + buffer should not be rendered
+ expect(mockData.renderProgram).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render programs within buffer zone', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60),
+ createProgramAtTime('prog-2', 1, 60),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 2 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Programs within H_BUFFER (600px) should be rendered
+ const renderedPrograms = mockData.renderProgram.mock.calls.map(
+ call => call[0]
+ );
+
+ expect(renderedPrograms.length).toBeGreaterThan(0);
+ });
+
+ it('should not render programs completely outside viewport and buffer', () => {
+ const programs = [
+ createProgramAtTime('prog-far-left', 0, 60),
+ createProgramAtTime('prog-visible', 10, 60),
+ createProgramAtTime('prog-far-right', 22, 60),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ const renderedPrograms = mockData.renderProgram.mock.calls.map(
+ call => call[0].id
+ );
+
+ // Only visible program should be rendered
+ expect(renderedPrograms).toContain('prog-visible');
+ expect(renderedPrograms).not.toContain('prog-far-left');
+ expect(renderedPrograms).not.toContain('prog-far-right');
+ });
+
+ it('should handle edge case where program spans viewport boundary', () => {
+ const programs = [
+ createProgramAtTime('prog-spanning', 5, 180), // 3-hour program
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 6 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Program spanning viewport should be visible
+ expect(screen.getByTestId('program-prog-spanning')).toBeInTheDocument();
+ });
+
+ it('should update visible programs when scroll position changes', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60),
+ createProgramAtTime('prog-2', 12, 60),
+ ];
+
+ const scrollRef = { current: 0 };
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: scrollRef,
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const { rerender } = render(
+
+ );
+
+ const initialCalls = mockData.renderProgram.mock.calls.length;
+
+ // Scroll to different position
+ scrollRef.current = HOUR_WIDTH * 12;
+ const newData = { ...data, guideScrollLeftRef: scrollRef };
+
+ rerender();
+
+ // Different programs should be rendered
+ expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls);
+ });
+ });
+
+ describe('Placeholder Culling', () => {
+ it('should only render placeholders visible in viewport', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const { container } = render(
+
+ );
+
+ const visiblePlaceholders = screen.getAllByText('No program data');
+
+ // Should render fewer than total placeholders due to culling
+ expect(visiblePlaceholders.length).toBeLessThan(Math.ceil(24 / 2));
+ expect(visiblePlaceholders.length).toBeGreaterThan(0);
+ });
+
+ it('should not render placeholders outside viewport', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 20 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const { container } = render(
+
+ );
+
+ const visiblePlaceholders = screen.getAllByText('No program data');
+
+ // Near the end of timeline, should show fewer placeholders
+ expect(visiblePlaceholders.length).toBeGreaterThan(0);
+ expect(visiblePlaceholders.length).toBeLessThanOrEqual(3);
+ });
+ });
+
+ describe('Hover State Management', () => {
+ it('should show play icon on mouse enter', () => {
+ render();
+
+ const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
+
+ expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
+
+ fireEvent.mouseEnter(logoContainer);
+
+ expect(screen.getByTestId('play-icon')).toBeInTheDocument();
+ });
+
+ it('should hide play icon on mouse leave', () => {
+ render();
+
+ const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
+
+ fireEvent.mouseEnter(logoContainer);
+ expect(screen.getByTestId('play-icon')).toBeInTheDocument();
+
+ fireEvent.mouseLeave(logoContainer);
+ expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
+ });
+
+ it('should maintain hover state independently per row', () => {
+ const data = {
+ ...mockData,
+ filteredChannels: [mockChannel, { ...mockChannel, id: 'channel-2', name: 'Channel 2' }],
+ };
+
+ // Render both rows separately
+ const { container: container1 } = render(
+
+ );
+ const { container: container2 } = render(
+
+ );
+
+ // Hover over first row
+ const logo1 = container1.querySelector('.channel-logo');
+ fireEvent.mouseEnter(logo1);
+
+ // First row should show play icon
+ expect(container1.querySelector('[data-testid="play-icon"]')).toBeInTheDocument();
+
+ // Second row should not show play icon
+ expect(container2.querySelector('[data-testid="play-icon"]')).not.toBeInTheDocument();
+ });
+
+ });
+
+ describe('Program Time Positioning', () => {
+ const createTimedProgram = (startHour, durationMinutes) => {
+ const timelineStart = new Date('2024-01-01T00:00:00Z');
+ const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
+ return {
+ id: `program-${startHour}`,
+ title: `Program at ${startHour}h`,
+ startMs,
+ endMs: startMs + durationMinutes * 60 * 1000,
+ };
+ };
+
+ it('should calculate correct viewport boundaries', () => {
+ const programs = [
+ createTimedProgram(6, 60),
+ createTimedProgram(12, 60),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Both programs should be rendered since they're within viewport range
+ expect(mockData.renderProgram).toHaveBeenCalled();
+ });
+
+ it('should handle programs at timeline boundaries', () => {
+ const programs = [
+ createTimedProgram(0, 60), // Start of timeline
+ createTimedProgram(23, 60), // End of timeline
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: HOUR_WIDTH * 24,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getByText('Program at 0h')).toBeInTheDocument();
+ expect(screen.getByText('Program at 23h')).toBeInTheDocument();
+ });
+
+ it('should handle very short programs', () => {
+ const programs = [
+ createTimedProgram(12, 5), // 5-minute program
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 12 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getByTestId('program-program-12')).toBeInTheDocument();
+ });
+
+ it('should handle very long programs', () => {
+ const programs = [
+ createTimedProgram(6, 360), // 6-hour program
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Long program spanning viewport should be visible
+ expect(screen.getByTestId('program-program-6')).toBeInTheDocument();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty programsByChannelId gracefully', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map(),
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getAllByText('No program data').length).toBeGreaterThan(0);
+ });
+
+ it('should handle zero viewport width', () => {
+ const programs = [mockProgram];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: 0,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Should still render due to buffer
+ expect(screen.getByTestId('guide-row')).toBeInTheDocument();
+ });
+
+ it('should handle negative scroll position', () => {
+ const programs = [createProgramAtTime('prog-1', 0, 60)];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: -100 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getByTestId('guide-row')).toBeInTheDocument();
+ });
+
+ it('should handle row index out of bounds', () => {
+ const data = {
+ ...mockData,
+ filteredChannels: [mockChannel],
+ };
+
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should memoize component to prevent unnecessary re-renders', () => {
+ const { rerender } = render(
+
+ );
+
+ const renderCount = mockData.renderProgram.mock.calls.length;
+
+ // Re-render with same props
+ rerender();
+
+ // Should not cause additional renders due to React.memo
+ expect(mockData.renderProgram.mock.calls.length).toBe(renderCount);
+ });
+ });
});
diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx
index 341d0c32..d3163b1d 100644
--- a/frontend/src/components/__tests__/Sidebar.test.jsx
+++ b/frontend/src/components/__tests__/Sidebar.test.jsx
@@ -16,6 +16,10 @@ vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
+vi.mock('../NotificationCenter', () => ({
+ default: () => Notification Center
,
+}));
+
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => ,
@@ -30,6 +34,12 @@ vi.mock('lucide-react', () => ({
LogOut: ({ onClick }) => ,
User: ({ onClick }) => ,
FileImage: ({ onClick }) => ,
+ Webhook: () => ,
+ Logs: () => ,
+ ChevronDown: () => ,
+ ChevronRight: () => ,
+ MonitorCog: () => ,
+ Blocks: () => ,
}));
// Mock UserForm component
@@ -85,14 +95,11 @@ vi.mock('@mantine/core', async () => {
{children}
),
+ ScrollArea: ({ children }) => {children}
,
};
});
-const mockChannels = {
- 'channel-1': { id: 'channel-1', name: 'Channel 1' },
- 'channel-2': { id: 'channel-2', name: 'Channel 2' },
- 'channel-3': { id: 'channel-3', name: 'Channel 3' },
-};
+const mockChannels = [ 'channel-1', 'channel-2', 'channel-3' ];
const mockEnvironment = {
public_ip: '192.168.1.1',
@@ -182,7 +189,7 @@ describe('Sidebar', () => {
});
describe('Navigation Links - Admin User', () => {
- it('should render all admin navigation items', () => {
+ it('should render all admin navigation items', async () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
@@ -192,9 +199,16 @@ describe('Sidebar', () => {
expect(screen.getByText('DVR')).toBeInTheDocument();
expect(screen.getByText('Stats')).toBeInTheDocument();
expect(screen.getByText('Plugins')).toBeInTheDocument();
- expect(screen.getByText('Users')).toBeInTheDocument();
- expect(screen.getByText('Logo Manager')).toBeInTheDocument();
- expect(screen.getByText('Settings')).toBeInTheDocument();
+
+ // Expand System group to access Users
+ const systemButton = screen.getByText('System');
+ fireEvent.click(systemButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Logo Manager')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
});
it('should display channel count badge', () => {
@@ -452,4 +466,163 @@ describe('Sidebar', () => {
expect(flag).toBeInTheDocument();
});
});
+
+ describe('NavGroup Component', () => {
+ it('should render Integrations group with children collapsed by default', () => {
+ renderSidebar();
+
+ expect(screen.getByText('Integrations')).toBeInTheDocument();
+ expect(screen.queryByText('Connections')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logs')).not.toBeInTheDocument();
+ });
+
+ it('should expand Integrations group when clicked', async () => {
+ renderSidebar();
+
+ const integrationsGroup = screen.getByText('Integrations').closest('button');
+ fireEvent.click(integrationsGroup);
+
+ await waitFor(() => {
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ expect(screen.getByText('Logs')).toBeInTheDocument();
+ });
+ });
+
+ it('should collapse Integrations group when clicked again', async () => {
+ renderSidebar();
+
+ const integrationsGroup = screen.getByText('Integrations').closest('button');
+
+ // Expand
+ fireEvent.click(integrationsGroup);
+ await waitFor(() => {
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ });
+
+ // Collapse
+ fireEvent.click(integrationsGroup);
+ await waitFor(() => {
+ expect(screen.queryByText('Connections')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logs')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should render System group with children collapsed by default', () => {
+ renderSidebar();
+
+ expect(screen.getByText('System')).toBeInTheDocument();
+ expect(screen.queryByText('Users')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
+ });
+
+ it('should expand System group when clicked', async () => {
+ renderSidebar();
+
+ const systemGroup = screen.getByText('System').closest('button');
+ fireEvent.click(systemGroup);
+
+ await waitFor(() => {
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Logo Manager')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+ });
+
+ it('should hide group label when collapsed sidebar', () => {
+ renderSidebar({ collapsed: true });
+
+ expect(screen.queryByText('Integrations')).not.toBeInTheDocument();
+ expect(screen.queryByText('System')).not.toBeInTheDocument();
+ });
+
+ it('should not show multiple groups collapsed when both expanded', async () => {
+ renderSidebar();
+
+ const integrationsGroup = screen.getByText('Integrations').closest('button');
+ const systemGroup = screen.getByText('System').closest('button');
+
+ // Expand Integrations
+ fireEvent.click(integrationsGroup);
+ await waitFor(() => {
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ });
+
+ // Expand System (Integrations should remain expanded)
+ fireEvent.click(systemGroup);
+ await waitFor(() => {
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('NotificationCenter Integration', () => {
+ it('should render NotificationCenter when authenticated and expanded', () => {
+ renderSidebar();
+
+ expect(screen.getByTestId('notification-center')).toBeInTheDocument();
+ });
+
+ it('should render NotificationCenter when authenticated and collapsed', () => {
+ renderSidebar({ collapsed: true });
+
+ expect(screen.getByTestId('notification-center')).toBeInTheDocument();
+ });
+
+ it('should not render NotificationCenter when not authenticated', () => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: false,
+ user: null,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+
+ expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
+ });
+
+ it('should not render NotificationCenter when not authenticated and collapsed', () => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: false,
+ user: null,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+
+ renderSidebar({ collapsed: true });
+
+ expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Channel Count Badge', () => {
+ it('should display 0 when no channels exist', () => {
+ useChannelsStore.mockReturnValue({});
+
+ renderSidebar();
+
+ expect(screen.getByText('(0)')).toBeInTheDocument();
+ });
+
+ it('should handle null channelIds gracefully', () => {
+ useChannelsStore.mockReturnValue(null);
+
+ renderSidebar();
+
+ expect(screen.getByText('(0)')).toBeInTheDocument();
+ });
+
+ it('should handle array of channel IDs', () => {
+ useChannelsStore.mockReturnValue(['channel-1', 'channel-2', 'channel-3']);
+
+ renderSidebar();
+
+ expect(screen.getByText('(3)')).toBeInTheDocument();
+ });
+ });
});
From d292676b7399396332665039b2a3f6bcba7dbb45 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Mon, 2 Mar 2026 23:23:01 -0800
Subject: [PATCH 06/15] Component cleanup and refactoring
---
.../src/components/M3URefreshNotification.jsx | 24 +---------
.../src/components/NotificationCenter.jsx | 48 +++++++++++--------
frontend/src/components/Sidebar.jsx | 32 ++++++-------
3 files changed, 45 insertions(+), 59 deletions(-)
diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx
index af7f9aa5..5b40cadb 100644
--- a/frontend/src/components/M3URefreshNotification.jsx
+++ b/frontend/src/components/M3URefreshNotification.jsx
@@ -48,7 +48,7 @@ export default function M3URefreshNotification() {
const refreshProgress = usePlaylistsStore((s) => s.refreshProgress);
const fetchStreams = useStreamsStore((s) => s.fetchStreams);
const fetchChannelGroups = useChannelsStore((s) => s.fetchChannelGroups);
- const fetchChannelIds = useChannelsStore((s) => s.fetchChannelIds);
+ const fetchChannels = useChannelsStore((s) => s.fetchChannels);
const fetchPlaylists = usePlaylistsStore((s) => s.fetchPlaylists);
const fetchEPGData = useEPGsStore((s) => s.fetchEPGData);
const fetchCategories = useVODStore((s) => s.fetchCategories);
@@ -151,28 +151,6 @@ export default function M3URefreshNotification() {
triggerPostCompletionFetches(data.action);
}
- if (taskProgress == 0) {
- message = `${message} starting...`;
- } else if (taskProgress == 100) {
- message = `${message} complete!`;
-
- // Only trigger additional fetches on successful completion
- if (data.action == 'parsing') {
- fetchStreams();
- API.requeryChannels();
- fetchChannelIds();
- } else if (data.action == 'processing_groups') {
- fetchStreams();
- fetchChannelGroups();
- fetchEPGData();
- fetchPlaylists();
- } else if (data.action == 'vod_refresh') {
- // VOD refresh completed, trigger VOD categories refresh
- fetchPlaylists(); // Refresh playlist data to show updated VOD info
- fetchCategories(); // Refresh VOD categories to make them visible
- }
- }
-
showNotification({
title: `M3U Processing: ${playlist.name}`,
message,
diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx
index f7da56ad..b6bbdcfb 100644
--- a/frontend/src/components/NotificationCenter.jsx
+++ b/frontend/src/components/NotificationCenter.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
@@ -10,7 +10,9 @@ import {
Group,
Indicator,
Popover,
- ScrollArea,
+ PopoverDropdown,
+ PopoverTarget,
+ ScrollAreaAutosize,
Stack,
Text,
ThemeIcon,
@@ -18,22 +20,26 @@ import {
useMantineTheme,
} from '@mantine/core';
import {
+ AlertTriangle,
+ ArrowRight,
Bell,
Check,
CheckCheck,
Download,
ExternalLink,
- Info,
- Settings,
- AlertTriangle,
- Megaphone,
- X,
Eye,
EyeOff,
- ArrowRight,
+ Info,
+ Megaphone,
+ Settings,
+ X,
} from 'lucide-react';
import useNotificationsStore from '../store/notifications';
-import API from '../api';
+import {
+ dismissAllNotifications,
+ dismissNotification,
+ getNotifications,
+} from '../utils/components/NotificationCenterUtils.js';
// Get icon for notification type
const getNotificationIcon = (type) => {
@@ -139,7 +145,9 @@ const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
color="gray"
size="sm"
onClick={handleDismiss}
- style={{ position: 'absolute', top: 8, right: 8 }}
+ pos='absolute'
+ top={8}
+ right={8}
>
@@ -149,7 +157,7 @@ const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
{getNotificationIcon(notification.notification_type)}
-
+
{notification.title}
@@ -249,7 +257,7 @@ const NotificationCenter = ({ onSettingAction }) => {
// Fetch notifications on mount and periodically
const fetchNotifications = useCallback(async () => {
try {
- await API.getNotifications(showDismissed);
+ await getNotifications(showDismissed);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
@@ -265,7 +273,7 @@ const NotificationCenter = ({ onSettingAction }) => {
const handleDismiss = async (notificationId, actionTaken = null) => {
try {
- await API.dismissNotification(notificationId, actionTaken);
+ await dismissNotification(notificationId, actionTaken);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
@@ -273,7 +281,7 @@ const NotificationCenter = ({ onSettingAction }) => {
const handleDismissAll = async () => {
try {
- await API.dismissAllNotifications();
+ await dismissAllNotifications();
} catch (error) {
console.error('Failed to dismiss all notifications:', error);
}
@@ -302,7 +310,7 @@ const NotificationCenter = ({ onSettingAction }) => {
shadow="lg"
withArrow
>
-
+
{
-
+
-
+
{/* Header */}
@@ -367,7 +375,7 @@ const NotificationCenter = ({ onSettingAction }) => {
{/* Notification list */}
-
+
{displayedNotifications.length === 0 ? (
{
))}
)}
-
+
{/* Footer with info text */}
{!showDismissed &&
@@ -421,7 +429,7 @@ const NotificationCenter = ({ onSettingAction }) => {
>
)}
-
+
);
};
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index a90a8d96..67f721cd 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -84,18 +84,16 @@ function NavGroup({ label, icon, paths, location, collapsed }) {
.includes(location.pathname);
return (
-
- setOpen((o) => !o)}
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
- style={{ width: '100%' }}
>
{icon}
{!collapsed && (
-
+
-
+
{open ? : }
@@ -117,13 +115,13 @@ function NavGroup({ label, icon, paths, location, collapsed }) {
{open && (
-
+
{paths.map((child) => {
const active = location.pathname === child.path;
return (
{
p="xs"
mih="100vh"
display='flex'
+ br='1px solid #2A2A2E'
style={{
backgroundColor: '#1A1A1E',
- borderRight: '1px solid #2A2A2E',
flexDirection: 'column',
}}
>
@@ -297,9 +295,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{
mt='auto'
p={16}
display='flex'
+ bt='1px solid #2A2A2E'
style={{
alignItems: 'center',
gap: 10,
- borderTop: '1px solid #2A2A2E',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
@@ -378,7 +376,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{!collapsed && authUser && (
@@ -404,7 +403,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{!collapsed && (
v{appVersion?.version || '0.0.0'}
@@ -415,9 +415,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
{collapsed && isAuthenticated && (
From 3e31f98d0f6eed2d30db18ed8fff5774d00a3a00 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Mon, 2 Mar 2026 23:23:11 -0800
Subject: [PATCH 07/15] Extracted NotificationCenterUtils
---
.../src/utils/components/NotificationCenterUtils.js | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 frontend/src/utils/components/NotificationCenterUtils.js
diff --git a/frontend/src/utils/components/NotificationCenterUtils.js b/frontend/src/utils/components/NotificationCenterUtils.js
new file mode 100644
index 00000000..a253c0d3
--- /dev/null
+++ b/frontend/src/utils/components/NotificationCenterUtils.js
@@ -0,0 +1,11 @@
+import API from '../../api.js';
+
+export const getNotifications = (showDismissed) => {
+ return API.getNotifications(showDismissed);
+};
+export const dismissNotification = (notificationId, actionTaken) => {
+ return API.dismissNotification(notificationId, actionTaken);
+};
+export const dismissAllNotifications = () => {
+ return API.dismissAllNotifications();
+};
\ No newline at end of file
From 8d7752c004d6181d3aef568ab1d52f6ad87953a6 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Mon, 2 Mar 2026 23:23:21 -0800
Subject: [PATCH 08/15] Added NotificationCenter tests
---
.../__tests__/NotificationCenter.test.jsx | 662 ++++++++++++++++++
.../__tests__/NotificationCenterUtils.test.js | 83 +++
2 files changed, 745 insertions(+)
create mode 100644 frontend/src/components/__tests__/NotificationCenter.test.jsx
create mode 100644 frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js
diff --git a/frontend/src/components/__tests__/NotificationCenter.test.jsx b/frontend/src/components/__tests__/NotificationCenter.test.jsx
new file mode 100644
index 00000000..9dfb13f5
--- /dev/null
+++ b/frontend/src/components/__tests__/NotificationCenter.test.jsx
@@ -0,0 +1,662 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { BrowserRouter } from 'react-router-dom';
+import NotificationCenter from '../NotificationCenter';
+import useNotificationsStore from '../../store/notifications';
+import * as NotificationUtils from '../../utils/components/NotificationCenterUtils';
+
+// Mock the notifications store
+vi.mock('../../store/notifications');
+
+// Mock the notification utils
+vi.mock('../../utils/components/NotificationCenterUtils', () => ({
+ getNotifications: vi.fn(),
+ dismissNotification: vi.fn(),
+ dismissAllNotifications: vi.fn(),
+}));
+
+// Mock react-router-dom
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Popover: ({ children, opened }) => (
+
+ {children}
+
+ ),
+ PopoverTarget: ({ children }) => {children}
,
+ PopoverDropdown: ({ children }) => {children}
,
+ Indicator: ({ children, label, disabled, processing }) => (
+
+ {children}
+
+ ),
+ ActionIcon: ({ children, onClick, 'aria-label': ariaLabel, ...props }) => (
+
+ ),
+ ScrollAreaAutosize: ({ children }) => {children}
,
+ Badge: ({ children, ...props }) => {children},
+ Card: ({ children, ...props }) => {children}
,
+ ThemeIcon: ({ children, ...props }) => {children}
,
+ Group: ({ children, ...props }) => {children}
,
+ Stack: ({ children, ...props }) => {children}
,
+ Box: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children},
+ Button: ({ children, onClick, ...props }) => (
+
+ ),
+ Divider: () =>
,
+ Tooltip: ({ children, label }) => (
+
+ {children}
+
+ ),
+ useMantineTheme: () => ({
+ colors: {
+ red: ['', '', '', '', '', '#fa5252', '', '', '', '#c92a2a'],
+ orange: ['', '', '', '', '', '#fd7e14'],
+ blue: ['', '', '', '', '', '#228be6'],
+ gray: ['', '', '', '', '', '#adb5bd'],
+ green: ['', '', '', '', '', '#51cf66'],
+ violet: ['', '', '', '', '', '#7950f2'],
+ },
+ }),
+ };
+});
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ Bell: () => Bell,
+ Check: () => Check,
+ CheckCheck: () => CheckCheck,
+ Download: () => Download,
+ ExternalLink: () => ExternalLink,
+ Info: () => Info,
+ Settings: () => Settings,
+ AlertTriangle: () => AlertTriangle,
+ Megaphone: () => Megaphone,
+ X: () => X,
+ Eye: () => Eye,
+ EyeOff: () => EyeOff,
+ ArrowRight: () => ArrowRight,
+}));
+
+const mockNotifications = [
+ {
+ id: 1,
+ title: 'Version Update Available',
+ message: 'Version 2.0 is available',
+ notification_type: 'version_update',
+ priority: 'high',
+ is_dismissed: false,
+ created_at: '2024-01-01T10:00:00Z',
+ action_data: {
+ release_url: 'https://github.com/releases/v2.0',
+ },
+ },
+ {
+ id: 2,
+ title: 'Setting Recommendation',
+ message: 'Enable dark mode for better experience',
+ notification_type: 'setting_recommendation',
+ priority: 'normal',
+ is_dismissed: false,
+ created_at: '2024-01-02T10:00:00Z',
+ action_data: {},
+ },
+ {
+ id: 3,
+ title: 'System Announcement',
+ message: 'Maintenance scheduled for tomorrow',
+ notification_type: 'announcement',
+ priority: 'normal',
+ is_dismissed: true,
+ created_at: '2024-01-03T10:00:00Z',
+ action_data: {},
+ },
+ {
+ id: 4,
+ title: 'Critical Warning',
+ message: 'System issue detected',
+ notification_type: 'warning',
+ priority: 'critical',
+ is_dismissed: false,
+ created_at: '2024-01-04T10:00:00Z',
+ action_data: {},
+ },
+];
+
+describe('NotificationCenter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // vi.useFakeTimers();
+
+ // Default store mock
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: mockNotifications,
+ unreadCount: 3,
+ getUnreadNotifications: vi.fn(() =>
+ mockNotifications.filter((n) => !n.is_dismissed)
+ ),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ NotificationUtils.getNotifications.mockResolvedValue();
+ NotificationUtils.dismissNotification.mockResolvedValue();
+ NotificationUtils.dismissAllNotifications.mockResolvedValue();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ });
+
+ const renderComponent = (props = {}) => {
+ return render(
+
+
+
+ );
+ };
+
+ describe('Bell Icon and Indicator', () => {
+ it('should render bell icon', () => {
+ renderComponent();
+ expect(screen.getByLabelText('Notifications')).toBeInTheDocument();
+ expect(screen.getByTestId('bell-icon')).toBeInTheDocument();
+ });
+
+ it('should show unread count indicator when there are unread notifications', () => {
+ renderComponent();
+ const indicator = screen.getByTestId('indicator');
+ expect(indicator).toHaveAttribute('data-label', '3');
+ expect(indicator).toHaveAttribute('data-disabled', 'false');
+ expect(indicator).toHaveAttribute('data-processing', 'true');
+ });
+
+ it('should not show indicator when unread count is zero', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [],
+ unreadCount: 0,
+ getUnreadNotifications: vi.fn(() => []),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ const indicator = screen.getByTestId('indicator');
+ expect(indicator).toHaveAttribute('data-disabled', 'true');
+ });
+
+ it('should show "9+" when unread count exceeds 9', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: mockNotifications,
+ unreadCount: 15,
+ getUnreadNotifications: vi.fn(() => mockNotifications),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ const indicator = screen.getByTestId('indicator');
+ expect(indicator).toHaveAttribute('data-label', '9+');
+ });
+ });
+
+ describe('Popover Toggle', () => {
+ it('should open popover when bell icon is clicked', () => {
+ renderComponent();
+ const bellButton = screen.getByLabelText('Notifications');
+
+ fireEvent.click(bellButton);
+
+ const popover = screen.getByTestId('popover');
+ expect(popover).toHaveAttribute('data-opened', 'true');
+ });
+
+ it('should close popover when bell icon is clicked again', () => {
+ renderComponent();
+ const bellButton = screen.getByLabelText('Notifications');
+
+ fireEvent.click(bellButton);
+ fireEvent.click(bellButton);
+
+ const popover = screen.getByTestId('popover');
+ expect(popover).toHaveAttribute('data-opened', 'false');
+ });
+
+ it('should display notification header with count', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('Notifications')).toBeInTheDocument();
+ expect(screen.getByText('3 new')).toBeInTheDocument();
+ });
+ });
+
+ describe('API Calls', () => {
+ it('should fetch notifications on mount', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(NotificationUtils.getNotifications).toHaveBeenCalledWith(false);
+ });
+ });
+
+ it('should fetch notifications every 5 minutes', () => {
+ vi.useFakeTimers();
+
+ renderComponent();
+
+ expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(5 * 60 * 1000);
+
+ expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(2);
+
+ vi.useRealTimers();
+ });
+
+ it('should handle API errors gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ NotificationUtils.getNotifications.mockRejectedValue(new Error('Network error'));
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to fetch notifications:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Notification Display', () => {
+ it('should display unread notifications by default', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('Version Update Available')).toBeInTheDocument();
+ expect(screen.getByText('Setting Recommendation')).toBeInTheDocument();
+ expect(screen.getByText('Critical Warning')).toBeInTheDocument();
+ expect(screen.queryByText('System Announcement')).not.toBeInTheDocument();
+ });
+
+ it('should display all notifications when show dismissed is toggled', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const eyeButtons = screen.getAllByTestId(/action-icon-/);
+ const toggleButton = eyeButtons.find(btn =>
+ btn.querySelector('[data-testid="eye-icon"]')
+ );
+ fireEvent.click(toggleButton);
+
+ expect(screen.getByText('System Announcement')).toBeInTheDocument();
+ });
+
+ it('should show empty state when no unread notifications', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [],
+ unreadCount: 0,
+ getUnreadNotifications: vi.fn(() => []),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('All caught up!')).toBeInTheDocument();
+ expect(screen.getByText('No new notifications')).toBeInTheDocument();
+ });
+
+ it('should show dismissed count in footer', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('1 dismissed notification')).toBeInTheDocument();
+ });
+
+ it('should show correct dismissed count with multiple dismissed notifications', () => {
+ const notifications = [
+ ...mockNotifications,
+ { ...mockNotifications[2], id: 5, is_dismissed: true },
+ ];
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications,
+ unreadCount: 3,
+ getUnreadNotifications: vi.fn(() =>
+ notifications.filter((n) => !n.is_dismissed)
+ ),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('2 dismissed notifications')).toBeInTheDocument();
+ });
+ });
+
+ describe('Notification Actions', () => {
+ it('should dismiss notification when X button is clicked', async () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const xIcons = screen.getAllByTestId('x-icon');
+ fireEvent.click(xIcons[0].closest('button'));
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
+ });
+ });
+
+ it('should dismiss all notifications when CheckCheck button is clicked', async () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const checkCheckIcon = screen.getByTestId('checkcheck-icon');
+ fireEvent.click(checkCheckIcon.closest('button'));
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissAllNotifications).toHaveBeenCalled();
+ });
+ });
+
+ it('should open release URL for version update notification', () => {
+ const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => {});
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const viewReleaseButton = screen.getByText('View Release');
+ fireEvent.click(viewReleaseButton);
+
+ expect(windowOpen).toHaveBeenCalledWith(
+ 'https://github.com/releases/v2.0',
+ '_blank'
+ );
+
+ windowOpen.mockRestore();
+ });
+
+ it('should navigate for notifications with action_url', () => {
+ const notificationWithUrl = {
+ ...mockNotifications[0],
+ action_data: {
+ action_url: '/settings',
+ action_text: 'Go to Settings',
+ },
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [notificationWithUrl],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const actionButton = screen.getByText('Go to Settings');
+ fireEvent.click(actionButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/settings');
+ });
+
+ it('should call onSettingAction when applying setting recommendation', async () => {
+ const onSettingAction = vi.fn();
+ renderComponent({ onSettingAction });
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const applyButton = screen.getByText('Apply');
+ fireEvent.click(applyButton);
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'applied');
+ expect(onSettingAction).toHaveBeenCalledWith(mockNotifications[1]);
+ });
+ });
+
+ it('should dismiss setting recommendation when Ignore is clicked', async () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const ignoreButton = screen.getByText('Ignore');
+ fireEvent.click(ignoreButton);
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'dismissed');
+ });
+ });
+
+ it('should close popover when navigating with action_url', () => {
+ const notificationWithUrl = {
+ ...mockNotifications[0],
+ action_data: {
+ action_url: '/settings',
+ action_text: 'Go to Settings',
+ },
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [notificationWithUrl],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const actionButton = screen.getByText('Go to Settings');
+ fireEvent.click(actionButton);
+
+ const popover = screen.getByTestId('popover');
+ expect(popover).toHaveAttribute('data-opened', 'false');
+ });
+ });
+
+ describe('Notification Types and Icons', () => {
+ it('should render correct icon for version_update', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('download-icon')).toBeInTheDocument();
+ });
+
+ it('should render correct icon for setting_recommendation', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('settings-icon')).toBeInTheDocument();
+ });
+
+ it('should render correct icon for announcement', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [mockNotifications[2]],
+ unreadCount: 0,
+ getUnreadNotifications: vi.fn(() => []),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const eyeButtons = screen.getAllByTestId(/action-icon-/);
+ const toggleButton = eyeButtons.find(btn =>
+ btn.querySelector('[data-testid="eye-icon"]')
+ );
+ fireEvent.click(toggleButton);
+
+ expect(screen.getByTestId('megaphone-icon')).toBeInTheDocument();
+ });
+
+ it('should render correct icon for warning', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
+ });
+
+ it('should render info icon for unknown type', () => {
+ const unknownTypeNotification = {
+ ...mockNotifications[0],
+ notification_type: 'unknown',
+ is_dismissed: false,
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [unknownTypeNotification],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [unknownTypeNotification]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('info-icon')).toBeInTheDocument();
+ });
+ });
+
+ describe('Priority Badges', () => {
+ it('should show priority badge for high priority notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('high')).toBeInTheDocument();
+ });
+
+ it('should show priority badge for critical priority notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('critical')).toBeInTheDocument();
+ });
+
+ it('should not show priority badge for normal priority', () => {
+ const normalNotification = {
+ ...mockNotifications[1],
+ priority: 'normal',
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [normalNotification],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [normalNotification]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.queryByText('normal')).not.toBeInTheDocument();
+ });
+
+ it('should show dismissed badge for dismissed notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const eyeButtons = screen.getAllByTestId(/action-icon-/);
+ const toggleButton = eyeButtons.find(btn =>
+ btn.querySelector('[data-testid="eye-icon"]')
+ );
+ fireEvent.click(toggleButton);
+
+ expect(screen.getByText('Dismissed')).toBeInTheDocument();
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle dismiss notification errors', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ NotificationUtils.dismissNotification.mockRejectedValue(new Error('API error'));
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const xIcons = screen.getAllByTestId('x-icon');
+ fireEvent.click(xIcons[0].closest('button'));
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to dismiss notification:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+
+ it('should handle dismiss all notifications errors', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ NotificationUtils.dismissAllNotifications.mockRejectedValue(new Error('API error'));
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const checkCheckIcon = screen.getByTestId('checkcheck-icon');
+ fireEvent.click(checkCheckIcon.closest('button'));
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to dismiss all notifications:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Date Display', () => {
+ it('should display formatted date for notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const expectedDate = new Date('2024-01-01T10:00:00Z').toLocaleDateString();
+ expect(screen.getByText(expectedDate)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js b/frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js
new file mode 100644
index 00000000..26628b98
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as NotificationUtils from '../NotificationCenterUtils';
+import API from '../../../api';
+
+vi.mock('../../../api');
+
+describe('NotificationCenterUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getNotifications', () => {
+ it('should call API.getNotifications with showDismissed parameter', async () => {
+ const mockNotifications = [
+ { id: 1, message: 'Test notification' }
+ ];
+ API.getNotifications.mockResolvedValue(mockNotifications);
+
+ const result = await NotificationUtils.getNotifications(false);
+
+ expect(API.getNotifications).toHaveBeenCalledWith(false);
+ expect(result).toEqual(mockNotifications);
+ });
+
+ it('should call API.getNotifications with showDismissed=true', async () => {
+ const mockNotifications = [
+ { id: 2, message: 'Dismissed notification', is_dismissed: true }
+ ];
+ API.getNotifications.mockResolvedValue(mockNotifications);
+
+ const result = await NotificationUtils.getNotifications(true);
+
+ expect(API.getNotifications).toHaveBeenCalledWith(true);
+ expect(result).toEqual(mockNotifications);
+ });
+
+ it('should handle API errors', async () => {
+ const error = new Error('API error');
+ API.getNotifications.mockRejectedValue(error);
+
+ await expect(NotificationUtils.getNotifications(false)).rejects.toThrow('API error');
+ expect(API.getNotifications).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('dismissNotification', () => {
+ it('should call API.dismissNotification with notificationId and actionTaken', async () => {
+ API.dismissNotification.mockResolvedValue({ success: true });
+
+ const result = await NotificationUtils.dismissNotification(1, 'dismissed');
+
+ expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
+ expect(result).toEqual({ success: true });
+ });
+
+ it('should handle API errors when dismissing', async () => {
+ const error = new Error('Dismiss failed');
+ API.dismissNotification.mockRejectedValue(error);
+
+ await expect(NotificationUtils.dismissNotification(1, 'dismissed')).rejects.toThrow('Dismiss failed');
+ expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
+ });
+ });
+
+ describe('dismissAllNotifications', () => {
+ it('should call API.dismissAllNotifications', async () => {
+ API.dismissAllNotifications.mockResolvedValue({ success: true, count: 5 });
+
+ const result = await NotificationUtils.dismissAllNotifications();
+
+ expect(API.dismissAllNotifications).toHaveBeenCalled();
+ expect(result).toEqual({ success: true, count: 5 });
+ });
+
+ it('should handle API errors when dismissing all', async () => {
+ const error = new Error('Dismiss all failed');
+ API.dismissAllNotifications.mockRejectedValue(error);
+
+ await expect(NotificationUtils.dismissAllNotifications()).rejects.toThrow('Dismiss all failed');
+ expect(API.dismissAllNotifications).toHaveBeenCalled();
+ });
+ });
+});
From a54bc2c513287c22bfa0ce2cd812e2117b0cb1ee Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Wed, 4 Mar 2026 08:44:50 -0800
Subject: [PATCH 09/15] Reverted styling/small syntax changes
---
frontend/src/components/FloatingVideo.jsx | 51 +++++++------
.../src/components/M3URefreshNotification.jsx | 11 ++-
.../src/components/NotificationCenter.jsx | 20 +++---
frontend/src/components/SeriesModal.jsx | 72 ++++++++++---------
frontend/src/components/Sidebar.jsx | 58 ++++++++-------
frontend/src/components/SystemEvents.jsx | 25 ++++---
frontend/src/components/VODModal.jsx | 67 ++++++++++-------
7 files changed, 163 insertions(+), 141 deletions(-)
diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx
index d25f7468..ae66b08c 100644
--- a/frontend/src/components/FloatingVideo.jsx
+++ b/frontend/src/components/FloatingVideo.jsx
@@ -1,9 +1,9 @@
// frontend/src/components/FloatingVideo.js
-import React, {useCallback, useEffect, useRef, useState} from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import mpegts from 'mpegts.js';
-import {Box, CloseButton, Flex, Loader, Text} from '@mantine/core';
+import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
import {
applyConstraints,
calculateNewDimensions,
@@ -644,15 +644,20 @@ export default function FloatingVideo() {
}}
>
{/* Simple header row with a close button */}
-
+
e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
- mih={32}
- miw={32}
style={{
+ minHeight: '32px',
+ minWidth: '32px',
cursor: 'pointer',
touchAction: 'manipulation',
}}
@@ -661,7 +666,7 @@ export default function FloatingVideo() {
{/* Video container with relative positioning for the overlay */}
{
if (contentType === 'vod' && !isLoading) {
setShowOverlay(true);
@@ -701,17 +706,17 @@ export default function FloatingVideo() {
{/* VOD title overlay when not loading - auto-hides after 4 seconds */}
{!isLoading && metadata && contentType === 'vod' && showOverlay && (
{metadata.year}
@@ -736,14 +741,14 @@ export default function FloatingVideo() {
{/* Loading overlay - only show when loading */}
{isLoading && (
-
+
Loading {contentType === 'vod' ? 'video' : 'stream'}...
@@ -761,13 +766,13 @@ export default function FloatingVideo() {
{/* Error message below video - doesn't block controls */}
{!isLoading && loadError && (
-
+
{loadError}
diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx
index 5b40cadb..8fd461b4 100644
--- a/frontend/src/components/M3URefreshNotification.jsx
+++ b/frontend/src/components/M3URefreshNotification.jsx
@@ -57,12 +57,16 @@ export default function M3URefreshNotification() {
const handleM3UUpdate = (data) => {
// Skip if status hasn't changed
- if (JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)) {
+ if (
+ JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)
+ ) {
return;
}
const playlist = playlists.find((pl) => pl.id == data.account);
- if (!playlist) return;
+ if (!playlist) {
+ return;
+ }
// Update notification status
setNotificationStatus(prev => ({
@@ -82,7 +86,8 @@ export default function M3URefreshNotification() {
}
// Skip if already errored
- if (notificationStatus[data.account]?.status === 'error') {
+ const currentStatus = notificationStatus[data.account];
+ if (currentStatus && currentStatus.status === 'error') {
return;
}
diff --git a/frontend/src/components/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx
index b6bbdcfb..252e7da0 100644
--- a/frontend/src/components/NotificationCenter.jsx
+++ b/frontend/src/components/NotificationCenter.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ActionIcon,
@@ -20,19 +20,19 @@ import {
useMantineTheme,
} from '@mantine/core';
import {
- AlertTriangle,
- ArrowRight,
Bell,
Check,
CheckCheck,
Download,
ExternalLink,
+ Info,
+ Settings,
+ AlertTriangle,
+ Megaphone,
+ X,
Eye,
EyeOff,
- Info,
- Megaphone,
- Settings,
- X,
+ ArrowRight,
} from 'lucide-react';
import useNotificationsStore from '../store/notifications';
import {
@@ -145,9 +145,7 @@ const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
color="gray"
size="sm"
onClick={handleDismiss}
- pos='absolute'
- top={8}
- right={8}
+ style={{ position: 'absolute', top: 8, right: 8 }}
>
@@ -157,7 +155,7 @@ const NotificationItem = ({ notification, onDismiss, onAction, onClose }) => {
{getNotificationIcon(notification.notification_type)}
-
+
{notification.title}
diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx
index 367e92a1..d22e6630 100644
--- a/frontend/src/components/SeriesModal.jsx
+++ b/frontend/src/components/SeriesModal.jsx
@@ -1,31 +1,31 @@
-import React, { useEffect, useState } from 'react';
+import React, { useState, useEffect } from 'react';
import {
- ActionIcon,
- Badge,
Box,
Button,
- Divider,
Flex,
Group,
Image,
- Loader,
- Modal,
+ Text,
+ Title,
Select,
+ Badge,
+ Loader,
Stack,
+ ActionIcon,
+ Modal,
+ Tabs,
Table,
+ Divider,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
- Tabs,
TabsList,
TabsPanel,
TabsTab,
- Text,
- Title,
} from '@mantine/core';
-import { Copy, Play } from 'lucide-react';
+import { Play, Copy } from 'lucide-react';
import { copyToClipboard } from '../utils';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
@@ -486,7 +486,7 @@ const SeriesModal = ({ series, opened, onClose }) => {
size="xl"
centered
>
-
+
{/* Backdrop image as background */}
{displaySeries.backdrop_path &&
displaySeries.backdrop_path.length > 0 && (
@@ -495,42 +495,42 @@ const SeriesModal = ({ series, opened, onClose }) => {
src={displaySeries.backdrop_path[0]}
alt={`${displaySeries.name} backdrop`}
fit="cover"
- pos="absolute"
- top={0}
- left={0}
- w={'100%'}
- h={'100%'}
- bdrs={8}
style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
objectFit: 'cover',
zIndex: 0,
+ borderRadius: 8,
filter: 'blur(2px) brightness(0.5)',
}}
/>
{/* Overlay for readability */}
>
)}
{/* Modal content above backdrop */}
-
+
{loadingDetails && (
-
+
Loading series details and episodes...
@@ -546,7 +546,9 @@ const SeriesModal = ({ series, opened, onClose }) => {
Stream Selection
- {loadingProviders && }
+ {loadingProviders && (
+
+ )}
{providers.length === 0 &&
!loadingProviders &&
@@ -576,7 +578,7 @@ const SeriesModal = ({ series, opened, onClose }) => {
value={selectedProvider?.id?.toString() || ''}
onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
- maw={350}
+ style={{ maxWidth: 350 }}
disabled={loadingProviders}
/>
) : null}
@@ -608,11 +610,11 @@ const SeriesModal = ({ series, opened, onClose }) => {
- Ep
+ Ep
Title
- Duration
- Date
- Action
+ Duration
+ Date
+ Action
@@ -633,19 +635,19 @@ const SeriesModal = ({ series, opened, onClose }) => {
{episode.name}
{episode.genre && (
-
+
{episode.genre}
)}
-
+
{formatDuration(episode.duration_secs)}
-
+
{getEpisodeAirdate(episode)}
@@ -704,7 +706,7 @@ const SeriesModal = ({ series, opened, onClose }) => {
))}
) : (
-
+
No episodes found for this series.
)}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 67f721cd..496a416c 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -66,7 +66,7 @@ const NavLink = ({ item, isActive, collapsed }) => {
)}
{!collapsed && item.badge && (
-
+
{item.badge}
)}
@@ -84,16 +84,17 @@ function NavGroup({ label, icon, paths, location, collapsed }) {
.includes(location.pathname);
return (
-
- setOpen((o) => !o)}
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
+ style={{ width: '100%' }}
>
{icon}
{!collapsed && (
-
+
-
+
{open ? : }
@@ -115,13 +116,13 @@ function NavGroup({ label, icon, paths, location, collapsed }) {
{open && (
-
+
{paths.map((child) => {
const active = location.pathname === child.path;
return (
{
@@ -259,15 +261,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{
{
{/* Profile Section */}
@@ -375,9 +377,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{!collapsed && authUser && (
@@ -402,9 +402,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{/* Version and Notification */}
{!collapsed && (
v{appVersion?.version || '0.0.0'}
@@ -415,9 +413,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
{collapsed && isAuthenticated && (
diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx
index df4bc087..1fcfd9db 100644
--- a/frontend/src/components/SystemEvents.jsx
+++ b/frontend/src/components/SystemEvents.jsx
@@ -171,17 +171,16 @@ const SystemEvents = () => {
const [events, setEvents] = useState([]);
const [totalEvents, setTotalEvents] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
+ const { ref: cardRef, width: cardWidth } = useElementSize();
+ const isNarrow = cardWidth < 650;
const [isLoading, setIsLoading] = useState(false);
- const [currentPage, setCurrentPage] = useState(1);
const [eventsRefreshInterval, setEventsRefreshInterval] = useLocalStorage(
'events-refresh-interval',
0
);
const [eventsLimit, setEventsLimit] = useLocalStorage('events-limit', 100);
-
- const { ref: cardRef, width: cardWidth } = useElementSize();
- const isNarrow = cardWidth < 650;
+ const [currentPage, setCurrentPage] = useState(1);
// Calculate offset based on current page and limit
const offset = (currentPage - 1) * eventsLimit;
@@ -225,13 +224,13 @@ const SystemEvents = () => {
padding="sm"
radius="md"
withBorder
- color="#fff"
- w={'100%'}
- maw={isExpanded ? '100%' : 800}
- ml="auto"
- mr="auto"
style={{
+ color: '#fff',
backgroundColor: '#27272A',
+ width: '100%',
+ maxWidth: isExpanded ? '100%' : '800px',
+ marginLeft: 'auto',
+ marginRight: 'auto',
transition: 'max-width 0.3s ease',
}}
>
@@ -251,7 +250,7 @@ const SystemEvents = () => {
min={10}
max={1000}
step={10}
- w={130}
+ style={{ width: 130 }}
/>