setConfirmDeleteOpen(false)}
+ onConfirm={() => deleteFilter(deleteTarget)}
+ title="Confirm Filter Deletion"
+ message={
+ filterToDelete ? (
+
+ {`Are you sure you want to delete the following filter?
+
+Type: ${filterToDelete.type}
+Patter: ${filterToDelete.regex_pattern}
+
+This action cannot be undone.`}
+
+ ) : (
+ 'Are you sure you want to delete this filter? This action cannot be undone.'
+ )
+ }
+ confirmLabel="Delete"
+ cancelLabel="Cancel"
+ actionKey="delete-filter"
+ onSuppressChange={suppressWarning}
+ size="md"
+ />
+ >
+ );
+};
+
+export default M3UFilters;
diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx
index e5918375..64c1d356 100644
--- a/frontend/src/components/forms/M3UGroupFilter.jsx
+++ b/frontend/src/components/forms/M3UGroupFilter.jsx
@@ -410,8 +410,13 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
if (newCustomProps.channel_sort_order === undefined) {
newCustomProps.channel_sort_order = '';
}
+ // Keep channel_sort_reverse if it exists
+ if (newCustomProps.channel_sort_reverse === undefined) {
+ newCustomProps.channel_sort_reverse = false;
+ }
} else {
delete newCustomProps.channel_sort_order;
+ delete newCustomProps.channel_sort_reverse; // Remove reverse when sort is removed
}
return {
@@ -428,36 +433,65 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => {
/>
{/* Show only channel_sort_order if selected */}
{group.custom_properties?.channel_sort_order !== undefined && (
- {
- setGroupStates(
- groupStates.map((state) => {
- if (state.channel_group === group.channel_group) {
- return {
- ...state,
- custom_properties: {
- ...state.custom_properties,
- channel_sort_order: value || '',
- },
- };
- }
- return state;
- })
- );
- }}
- data={[
- { value: '', label: 'Provider Order (Default)' },
- { value: 'name', label: 'Name' },
- { value: 'tvg_id', label: 'TVG ID' },
- { value: 'updated_at', label: 'Updated At' },
- ]}
- clearable
- searchable
- size="xs"
- />
+ <>
+ {
+ setGroupStates(
+ groupStates.map((state) => {
+ if (state.channel_group === group.channel_group) {
+ return {
+ ...state,
+ custom_properties: {
+ ...state.custom_properties,
+ channel_sort_order: value || '',
+ },
+ };
+ }
+ return state;
+ })
+ );
+ }}
+ data={[
+ { value: '', label: 'Provider Order (Default)' },
+ { value: 'name', label: 'Name' },
+ { value: 'tvg_id', label: 'TVG ID' },
+ { value: 'updated_at', label: 'Updated At' },
+ ]}
+ clearable
+ searchable
+ size="xs"
+ />
+
+ {/* Add reverse sort checkbox when sort order is selected (including default) */}
+ {group.custom_properties?.channel_sort_order !== undefined && (
+
+ {
+ setGroupStates(
+ groupStates.map((state) => {
+ if (state.channel_group === group.channel_group) {
+ return {
+ ...state,
+ custom_properties: {
+ ...state.custom_properties,
+ channel_sort_reverse: event.target.checked,
+ },
+ };
+ }
+ return state;
+ })
+ );
+ }}
+ size="xs"
+ />
+
+ )}
+ >
)}
{/* Show profile selection only if profile_assignment is selected */}
diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx
index 097a2ba8..991b7074 100644
--- a/frontend/src/components/tables/ChannelTableStreams.jsx
+++ b/frontend/src/components/tables/ChannelTableStreams.jsx
@@ -1,6 +1,7 @@
import React, { useMemo, useState, useEffect } from 'react';
import API from '../../api';
-import { GripHorizontal, SquareMinus } from 'lucide-react';
+import { copyToClipboard } from '../../utils';
+import { GripHorizontal, SquareMinus, ChevronDown, ChevronRight, Eye } from 'lucide-react';
import {
Box,
ActionIcon,
@@ -8,7 +9,14 @@ import {
Text,
useMantineTheme,
Center,
+ Badge,
+ Group,
+ Tooltip,
+ Collapse,
+ Button,
+
} from '@mantine/core';
+import { notifications } from '@mantine/notifications';
import {
useReactTable,
getCoreRowModel,
@@ -17,6 +25,8 @@ import {
import './table.css';
import useChannelsTableStore from '../../store/channelsTable';
import usePlaylistsStore from '../../store/playlists';
+import useVideoStore from '../../store/useVideoStore';
+import useSettingsStore from '../../store/settings';
import {
DndContext,
KeyboardSensor,
@@ -123,6 +133,15 @@ const ChannelStreams = ({ channel, isExpanded }) => {
);
const playlists = usePlaylistsStore((s) => s.playlists);
const authUser = useAuthStore((s) => s.user);
+ const showVideo = useVideoStore((s) => s.showVideo);
+ const env_mode = useSettingsStore((s) => s.environment.env_mode);
+ function handleWatchStream(streamHash) {
+ let vidUrl = `/proxy/ts/stream/${streamHash}`;
+ if (env_mode === 'dev') {
+ vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`;
+ }
+ showVideo(vidUrl);
+ }
const [data, setData] = useState(channelStreams || []);
@@ -141,6 +160,135 @@ const ChannelStreams = ({ channel, isExpanded }) => {
await API.requeryChannels();
};
+ // Create M3U account map for quick lookup
+ const m3uAccountsMap = useMemo(() => {
+ const map = {};
+ if (playlists && Array.isArray(playlists)) {
+ playlists.forEach((account) => {
+ if (account.id) {
+ map[account.id] = account.name;
+ }
+ });
+ }
+ return map;
+ }, [playlists]);
+
+ // Add state for tracking which streams have advanced stats expanded
+ const [expandedAdvancedStats, setExpandedAdvancedStats] = useState(new Set());
+
+ // Helper function to categorize stream stats
+ const categorizeStreamStats = (stats) => {
+ if (!stats) return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
+
+ const categories = {
+ basic: {},
+ video: {},
+ audio: {},
+ technical: {},
+ other: {}
+ };
+
+ // Define which stats go in which category
+ const categoryMapping = {
+ basic: ['resolution', 'video_codec', 'source_fps', 'audio_codec', 'audio_channels'],
+ video: ['video_bitrate', 'pixel_format', 'width', 'height', 'aspect_ratio', 'frame_rate'],
+ audio: ['audio_bitrate', 'sample_rate', 'audio_format', 'audio_channels_layout'],
+ technical: ['stream_type', 'container_format', 'duration', 'file_size', 'ffmpeg_output_bitrate', 'input_bitrate'],
+ other: [] // Will catch anything not categorized above
+ };
+
+ // Categorize each stat
+ Object.entries(stats).forEach(([key, value]) => {
+ let categorized = false;
+
+ for (const [category, keys] of Object.entries(categoryMapping)) {
+ if (keys.includes(key)) {
+ categories[category][key] = value;
+ categorized = true;
+ break;
+ }
+ }
+
+ // If not categorized, put it in 'other'
+ if (!categorized) {
+ categories.other[key] = value;
+ }
+ });
+
+ return categories;
+ };
+
+ // Function to format stat values for display
+ const formatStatValue = (key, value) => {
+ if (value === null || value === undefined) return 'N/A';
+
+ // Handle specific formatting cases
+ switch (key) {
+ case 'video_bitrate':
+ case 'audio_bitrate':
+ case 'ffmpeg_output_bitrate':
+ return `${value} kbps`;
+ case 'source_fps':
+ case 'frame_rate':
+ return `${value} fps`;
+ case 'sample_rate':
+ return `${value} Hz`;
+ case 'file_size':
+ // Convert bytes to appropriate unit
+ if (typeof value === 'number') {
+ if (value < 1024) return `${value} B`;
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`;
+ if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(2)} MB`;
+ return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
+ }
+ return value;
+ case 'duration':
+ // Format duration if it's in seconds
+ if (typeof value === 'number') {
+ const hours = Math.floor(value / 3600);
+ const minutes = Math.floor((value % 3600) / 60);
+ const seconds = Math.floor(value % 60);
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+ }
+ return value;
+ default:
+ return value.toString();
+ }
+ };
+
+ // Function to render a stats category
+ const renderStatsCategory = (categoryName, stats) => {
+ if (!stats || Object.keys(stats).length === 0) return null;
+
+ return (
+
+
+ {categoryName}
+
+
+ {Object.entries(stats).map(([key, value]) => (
+
+
+ {key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}: {formatStatValue(key, value)}
+
+
+ ))}
+
+
+ );
+ };
+
+ // Function to toggle advanced stats for a stream
+ const toggleAdvancedStats = (streamId) => {
+ const newExpanded = new Set(expandedAdvancedStats);
+ if (newExpanded.has(streamId)) {
+ newExpanded.delete(streamId);
+ } else {
+ newExpanded.add(streamId);
+ }
+ setExpandedAdvancedStats(newExpanded);
+ };
+
const table = useReactTable({
columns: useMemo(
() => [
@@ -152,14 +300,162 @@ const ChannelStreams = ({ channel, isExpanded }) => {
},
{
id: 'name',
- header: 'Name',
+ header: 'Stream Info',
accessorKey: 'name',
- },
- {
- id: 'm3u',
- header: 'M3U',
- accessorFn: (row) =>
- playlists.find((playlist) => playlist.id === row.m3u_account)?.name,
+ cell: ({ row }) => {
+ const stream = row.original;
+ const playlistName = playlists[stream.m3u_account]?.name || 'Unknown';
+ const accountName = m3uAccountsMap[stream.m3u_account] || playlistName;
+
+ // Categorize stream stats
+ const categorizedStats = categorizeStreamStats(stream.stream_stats);
+ const hasAdvancedStats = Object.values(categorizedStats).some(category =>
+ Object.keys(category).length > 0
+ );
+
+ return (
+
+
+ {stream.name}
+
+ {accountName}
+
+ {stream.quality && (
+
+ {stream.quality}
+
+ )}
+ {stream.url && (
+ <>
+
+ {
+ e.stopPropagation();
+ const success = await copyToClipboard(stream.url);
+ notifications.show({
+ title: success ? 'URL Copied' : 'Copy Failed',
+ message: success ? 'Stream URL copied to clipboard' : 'Failed to copy URL to clipboard',
+ color: success ? 'green' : 'red',
+ });
+ }}
+ >
+ URL
+
+
+
+ handleWatchStream(stream.stream_hash || stream.id)}
+ style={{ marginLeft: 2 }}
+ >
+
+
+
+ >
+ )}
+
+
+ {/* Basic Stream Stats (always shown) */}
+ {stream.stream_stats && (
+
+ {/* Video Information */}
+ {(stream.stream_stats.video_codec || stream.stream_stats.resolution || stream.stream_stats.video_bitrate || stream.stream_stats.source_fps) && (
+ <>
+ Video:
+ {stream.stream_stats.resolution && (
+
+ {stream.stream_stats.resolution}
+
+ )}
+ {stream.stream_stats.video_bitrate && (
+
+ {stream.stream_stats.video_bitrate} kbps
+
+ )}
+ {stream.stream_stats.source_fps && (
+
+ {stream.stream_stats.source_fps} FPS
+
+ )}
+ {stream.stream_stats.video_codec && (
+
+ {stream.stream_stats.video_codec.toUpperCase()}
+
+ )}
+ >
+ )}
+
+ {/* Audio Information */}
+ {(stream.stream_stats.audio_codec || stream.stream_stats.audio_channels) && (
+ <>
+ Audio:
+ {stream.stream_stats.audio_channels && (
+
+ {stream.stream_stats.audio_channels}
+
+ )}
+ {stream.stream_stats.audio_codec && (
+
+ {stream.stream_stats.audio_codec.toUpperCase()}
+
+ )}
+ >
+ )}
+
+ {/* Output Bitrate */}
+ {(stream.stream_stats.ffmpeg_output_bitrate) && (
+ <>
+ Output Bitrate:
+ {stream.stream_stats.ffmpeg_output_bitrate && (
+
+ {stream.stream_stats.ffmpeg_output_bitrate} kbps
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* Advanced Stats Toggle Button */}
+ {hasAdvancedStats && (
+
+ : }
+ onClick={() => toggleAdvancedStats(stream.id)}
+ c="dimmed"
+ >
+ {expandedAdvancedStats.has(stream.id) ? 'Hide' : 'Show'} Advanced Stats
+
+
+ )}
+
+ {/* Advanced Stats (expandable) */}
+
+
+ {renderStatsCategory('Video', categorizedStats.video)}
+ {renderStatsCategory('Audio', categorizedStats.audio)}
+ {renderStatsCategory('Technical', categorizedStats.technical)}
+ {renderStatsCategory('Other', categorizedStats.other)}
+
+ {/* Show when stats were last updated */}
+ {stream.stream_stats_updated_at && (
+
+ Last updated: {new Date(stream.stream_stats_updated_at).toLocaleString()}
+
+ )}
+
+
+
+ );
+ },
},
{
id: 'actions',
@@ -178,7 +474,7 @@ const ChannelStreams = ({ channel, isExpanded }) => {
),
},
],
- [data, playlists]
+ [data, playlists, m3uAccountsMap, expandedAdvancedStats]
),
data,
state: {
diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx
index 3d34ca55..3380fbd2 100644
--- a/frontend/src/components/tables/ChannelsTable.jsx
+++ b/frontend/src/components/tables/ChannelsTable.jsx
@@ -524,24 +524,12 @@ const ChannelsTable = ({ }) => {
};
const handleCopy = async (textToCopy, ref) => {
- try {
- await navigator.clipboard.writeText(textToCopy);
- notifications.show({
- title: 'Copied!',
- // style: { width: '200px', left: '200px' },
- });
- } catch (err) {
- const inputElement = ref.current; // Get the actual input
-
- if (inputElement) {
- inputElement.focus();
- inputElement.select();
-
- // For older browsers
- document.execCommand('copy');
- notifications.show({ title: 'Copied!' });
- }
- }
+ const success = await copyToClipboard(textToCopy);
+ notifications.show({
+ title: success ? 'Copied!' : 'Copy Failed',
+ message: success ? undefined : 'Failed to copy to clipboard',
+ color: success ? 'green' : 'red',
+ });
};
// Build URLs with parameters
const buildM3UUrl = () => {
@@ -564,25 +552,30 @@ const ChannelsTable = ({ }) => {
return params.toString() ? `${baseUrl}?${params.toString()}` : baseUrl;
};
// Example copy URLs
- const copyM3UUrl = () => {
- copyToClipboard(buildM3UUrl());
+ const copyM3UUrl = async () => {
+ const success = await copyToClipboard(buildM3UUrl());
notifications.show({
- title: 'M3U URL Copied!',
- message: 'The M3U URL has been copied to your clipboard.',
+ title: success ? 'M3U URL Copied!' : 'Copy Failed',
+ message: success ? 'The M3U URL has been copied to your clipboard.' : 'Failed to copy M3U URL to clipboard',
+ color: success ? 'green' : 'red',
});
};
- const copyEPGUrl = () => {
- copyToClipboard(buildEPGUrl());
+
+ const copyEPGUrl = async () => {
+ const success = await copyToClipboard(buildEPGUrl());
notifications.show({
- title: 'EPG URL Copied!',
- message: 'The EPG URL has been copied to your clipboard.',
+ title: success ? 'EPG URL Copied!' : 'Copy Failed',
+ message: success ? 'The EPG URL has been copied to your clipboard.' : 'Failed to copy EPG URL to clipboard',
+ color: success ? 'green' : 'red',
});
};
- const copyHDHRUrl = () => {
- copyToClipboard(hdhrUrl);
+
+ const copyHDHRUrl = async () => {
+ const success = await copyToClipboard(hdhrUrl);
notifications.show({
- title: 'HDHR URL Copied!',
- message: 'The HDHR URL has been copied to your clipboard.',
+ title: success ? 'HDHR URL Copied!' : 'Copy Failed',
+ message: success ? 'The HDHR URL has been copied to your clipboard.' : 'Failed to copy HDHR URL to clipboard',
+ color: success ? 'green' : 'red',
});
};
diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx
index c71789f2..d72fbeb4 100644
--- a/frontend/src/components/tables/M3UsTable.jsx
+++ b/frontend/src/components/tables/M3UsTable.jsx
@@ -803,7 +803,12 @@ const M3UTable = () => {
return (
{
padding: 0,
// gap: 1,
}}
- >
-
+ >
{
try {
const selectedChannelProfileId = useChannelsStore.getState().selectedProfileId;
+ // Try to fetch the actual stream data for selected streams
+ let streamsData = [];
+ try {
+ streamsData = await API.getStreamsByIds(selectedStreamIds);
+ } catch (error) {
+ console.warn('Could not fetch stream details, using IDs only:', error);
+ }
+
const streamData = selectedStreamIds.map(streamId => {
- const stream = data.find(s => s.id === streamId);
+ const stream = streamsData.find(s => s.id === streamId);
return {
stream_id: streamId,
name: stream?.name || `Stream ${streamId}`,
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
index 19f9955f..959edf3e 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -33,19 +33,23 @@ export const NETWORK_ACCESS_OPTIONS = {
export const PROXY_SETTINGS_OPTIONS = {
buffering_timeout: {
label: 'Buffering Timeout',
- description: 'Maximum time (in seconds) to wait for buffering before switching streams',
+ description:
+ 'Maximum time (in seconds) to wait for buffering before switching streams',
},
buffering_speed: {
label: 'Buffering Speed',
- description: 'Speed threshold below which buffering is detected (1.0 = normal speed)',
+ description:
+ 'Speed threshold below which buffering is detected (1.0 = normal speed)',
},
redis_chunk_ttl: {
label: 'Buffer Chunk TTL',
- description: 'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
+ description:
+ 'Time-to-live for buffer chunks in seconds (how long stream data is cached)',
},
channel_shutdown_delay: {
label: 'Channel Shutdown Delay',
- description: 'Delay in seconds before shutting down a channel after last client disconnects',
+ description:
+ 'Delay in seconds before shutting down a channel after last client disconnects',
},
channel_init_grace_period: {
label: 'Channel Initialization Grace Period',
@@ -53,6 +57,21 @@ export const PROXY_SETTINGS_OPTIONS = {
},
};
+export const M3U_FILTER_TYPES = [
+ {
+ label: 'Group',
+ value: 'group',
+ },
+ {
+ label: 'Stream Name',
+ value: 'name',
+ },
+ {
+ label: 'Stream URL',
+ value: 'url',
+ },
+];
+
export const REGION_CHOICES = [
{ value: 'ad', label: 'AD' },
{ value: 'ae', label: 'AE' },
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx
index c3709c17..19520a21 100644
--- a/frontend/src/pages/Stats.jsx
+++ b/frontend/src/pages/Stats.jsx
@@ -94,6 +94,7 @@ const ChannelCard = ({
const [activeStreamId, setActiveStreamId] = useState(null);
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
const [data, setData] = useState([]);
+ const [previewedStream, setPreviewedStream] = useState(null);
// Get M3U account data from the playlists store
const m3uAccounts = usePlaylistsStore((s) => s.playlists);
@@ -425,12 +426,29 @@ const ChannelCard = ({
// Get logo URL from the logos object if available
const logoUrl =
- channel.logo_id && logos && logos[channel.logo_id]
+ (channel.logo_id && logos && logos[channel.logo_id]
? logos[channel.logo_id].cache_url
- : null;
+ : null) ||
+ (previewedStream && previewedStream.logo_url) ||
+ null;
- // Ensure these values exist to prevent errors
- const channelName = channel.name || 'Unnamed Channel';
+ useEffect(() => {
+ let isMounted = true;
+ // Only fetch if we have a stream_id and NO channel.name
+ if (!channel.name && channel.stream_id) {
+ API.getStreamsByIds([channel.stream_id]).then((streams) => {
+ if (isMounted && streams && streams.length > 0) {
+ setPreviewedStream(streams[0]);
+ }
+ });
+ }
+ return () => { isMounted = false; };
+ }, [channel.name, channel.stream_id]);
+
+ const channelName =
+ channel.name ||
+ previewedStream?.name ||
+ 'Unnamed Channel';
const uptime = channel.uptime || 0;
const bitrates = channel.bitrates || [];
const totalBytes = channel.total_bytes || 0;
diff --git a/frontend/src/store/playlists.jsx b/frontend/src/store/playlists.jsx
index 87d8f3b8..30d01906 100644
--- a/frontend/src/store/playlists.jsx
+++ b/frontend/src/store/playlists.jsx
@@ -19,6 +19,26 @@ const usePlaylistsStore = create((set) => ({
editPlaylistId: id,
})),
+ fetchPlaylist: async (id) => {
+ set({ isLoading: true, error: null });
+ try {
+ const playlist = await api.getPlaylist(id);
+ set((state) => ({
+ playlists: state.playlists.map((p) => (p.id == id ? playlist : p)),
+ isLoading: false,
+ profiles: {
+ ...state.profiles,
+ [id]: playlist.profiles,
+ },
+ }));
+
+ return playlist;
+ } catch (error) {
+ console.error('Failed to fetch playlists:', error);
+ set({ error: 'Failed to load playlists.', isLoading: false });
+ }
+ },
+
fetchPlaylists: async () => {
set({ isLoading: true, error: null });
try {
@@ -91,9 +111,11 @@ const usePlaylistsStore = create((set) => ({
const existingProgress = state.refreshProgress[accountId];
// Don't replace 'initializing' status with empty/early server messages
- if (existingProgress &&
+ if (
+ existingProgress &&
existingProgress.action === 'initializing' &&
- accountIdOrData.progress === 0) {
+ accountIdOrData.progress === 0
+ ) {
return state; // Keep showing 'initializing' until real progress comes
}
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index a488ce8d..81836f0a 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -65,24 +65,54 @@ export const getDescendantProp = (obj, path) =>
path.split('.').reduce((acc, part) => acc && acc[part], obj);
export const copyToClipboard = async (value) => {
- let copied = false;
if (navigator.clipboard) {
// Modern method, using navigator.clipboard
try {
await navigator.clipboard.writeText(value);
- copied = true;
+ return true;
} catch (err) {
console.error('Failed to copy: ', err);
}
}
- if (!copied) {
- // Fallback method for environments without clipboard support
+ // Fallback method for environments without clipboard support
+ try {
const textarea = document.createElement('textarea');
textarea.value = value;
document.body.appendChild(textarea);
textarea.select();
- document.execCommand('copy');
+ const successful = document.execCommand('copy');
document.body.removeChild(textarea);
+ return successful;
+ } catch (err) {
+ console.error('Failed to copy with fallback method: ', err);
+ return false;
}
};
+
+export const setCustomProperty = (input, key, value, serialize = false) => {
+ let obj;
+
+ if (input == null) {
+ // matches null or undefined
+ obj = {};
+ } else if (typeof input === 'string') {
+ try {
+ obj = JSON.parse(input);
+ } catch (e) {
+ obj = {};
+ }
+ } else if (typeof input === 'object' && !Array.isArray(input)) {
+ obj = { ...input }; // shallow copy
+ } else {
+ obj = {};
+ }
+
+ obj[key] = value;
+
+ if (serialize === true) {
+ return JSON.stringify(obj);
+ }
+
+ return obj;
+};