first run at m3u filtering

This commit is contained in:
dekzter 2025-08-01 15:02:43 -04:00
parent 7b5a617bf8
commit ead76fe661
13 changed files with 1400 additions and 338 deletions

View file

@ -1,18 +1,38 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet, M3UAccountProfileViewSet
from .api_views import (
M3UAccountViewSet,
M3UFilterViewSet,
ServerGroupViewSet,
RefreshM3UAPIView,
RefreshSingleM3UAPIView,
UserAgentViewSet,
M3UAccountProfileViewSet,
)
app_name = 'm3u'
app_name = "m3u"
router = DefaultRouter()
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
router.register(r'accounts\/(?P<account_id>\d+)\/profiles', M3UAccountProfileViewSet, basename='m3u-account-profiles')
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
router.register(
r"accounts\/(?P<account_id>\d+)\/profiles",
M3UAccountProfileViewSet,
basename="m3u-account-profiles",
)
router.register(
r"accounts\/(?P<account_id>\d+)\/filters",
M3UFilterViewSet,
basename="m3u-filters",
)
router.register(r"server-groups", ServerGroupViewSet, basename="server-group")
urlpatterns = [
path('refresh/', RefreshM3UAPIView.as_view(), name='m3u_refresh'),
path('refresh/<int:account_id>/', RefreshSingleM3UAPIView.as_view(), name='m3u_refresh_single'),
path("refresh/", RefreshM3UAPIView.as_view(), name="m3u_refresh"),
path(
"refresh/<int:account_id>/",
RefreshSingleM3UAPIView.as_view(),
name="m3u_refresh_single",
),
]
urlpatterns += router.urls

View file

@ -183,8 +183,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
@ -194,6 +192,23 @@ class M3UFilterViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]
return M3UFilter.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs["account_id"]
# # Get the M3UAccount instance for the account_id
# m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context["m3u_account"] = account_id
# Perform the actual save
serializer.save(m3u_account_id=account_id)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-07-22 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.AlterField(
model_name='m3ufilter',
name='filter_type',
field=models.CharField(choices=[('group', 'Group'), ('name', 'Stream Name'), ('url', 'Stream URL')], default='group', help_text='Filter based on either group title or stream name.', max_length=50),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-07-31 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0013_alter_m3ufilter_filter_type'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='m3ufilter',
name='order',
field=models.PositiveIntegerField(default=0),
),
]

View file

@ -155,9 +155,11 @@ class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
("group", "Group Title"),
("group", "Group"),
("name", "Stream Name"),
("url", "Stream URL"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
@ -177,6 +179,7 @@ class M3UFilter(models.Model):
default=True,
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
@ -226,9 +229,6 @@ class ServerGroup(models.Model):
return self.name
from django.db import models
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""

View file

@ -16,11 +16,9 @@ logger = logging.getLogger(__name__)
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
channel_groups = ChannelGroupM3UAccountSerializer(source="m3u_account", many=True)
class Meta:
model = M3UFilter
fields = ["id", "filter_type", "regex_pattern", "exclude", "channel_groups"]
fields = ["id", "filter_type", "regex_pattern", "exclude", "order"]
class M3UAccountProfileSerializer(serializers.ModelSerializer):
@ -64,7 +62,7 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = M3UFilterSerializer(many=True, read_only=True)
filters = serializers.SerializerMethodField()
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@ -149,6 +147,10 @@ class M3UAccountSerializer(serializers.ModelSerializer):
return instance
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""

File diff suppressed because it is too large Load diff

View file

@ -256,7 +256,7 @@ export default class API {
hasChannels: false,
hasM3UAccounts: false,
canEdit: true,
canDelete: true
canDelete: true,
};
useChannelsStore.getState().addChannelGroup(processedGroup);
// Refresh channel groups to update the UI
@ -736,10 +736,13 @@ export default class API {
static async updateM3UGroupSettings(playlistId, groupSettings) {
try {
const response = await request(`${host}/api/m3u/accounts/${playlistId}/group-settings/`, {
method: 'PATCH',
body: { group_settings: groupSettings },
});
const response = await request(
`${host}/api/m3u/accounts/${playlistId}/group-settings/`,
{
method: 'PATCH',
body: { group_settings: groupSettings },
}
);
// Fetch the updated playlist and update the store
const updatedPlaylist = await API.getPlaylist(playlistId);
usePlaylistsStore.getState().updatePlaylist(updatedPlaylist);
@ -1110,6 +1113,48 @@ export default class API {
}
}
static async addM3UFilter(accountId, values) {
try {
const response = await request(
`${host}/api/m3u/accounts/${accountId}/filters/`,
{
method: 'POST',
body: values,
}
);
return response;
} catch (e) {
errorNotification(`Failed to add profile to account ${accountId}`, e);
}
}
static async deleteM3UFilter(accountId, id) {
try {
await request(`${host}/api/m3u/accounts/${accountId}/filters/${id}/`, {
method: 'DELETE',
});
} catch (e) {
errorNotification(`Failed to delete profile for account ${accountId}`, e);
}
}
static async updateM3UFilter(accountId, filterId, values) {
const { id, ...payload } = values;
try {
await request(
`${host}/api/m3u/accounts/${accountId}/filters/${filterId}/`,
{
method: 'PUT',
body: payload,
}
);
} catch (e) {
errorNotification(`Failed to update profile for account ${accountId}`, e);
}
}
static async getSettings() {
try {
const response = await request(`${host}/api/core/settings/`);
@ -1230,7 +1275,9 @@ export default class API {
static async getLogos(params = {}) {
try {
const queryParams = new URLSearchParams(params);
const response = await request(`${host}/api/channels/logos/?${queryParams.toString()}`);
const response = await request(
`${host}/api/channels/logos/?${queryParams.toString()}`
);
return response;
} catch (e) {
@ -1369,7 +1416,7 @@ export default class API {
});
// Remove multiple logos from store
ids.forEach(id => {
ids.forEach((id) => {
useChannelsStore.getState().removeLogo(id);
});

View file

@ -27,6 +27,7 @@ import usePlaylistsStore from '../../store/playlists';
import { notifications } from '@mantine/notifications';
import { isNotEmpty, useForm } from '@mantine/form';
import useEPGsStore from '../../store/epgs';
import M3UFilters from './M3UFilters';
const M3U = ({
m3uAccount = null,
@ -45,6 +46,7 @@ const M3U = ({
const [file, setFile] = useState(null);
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false);
const [filterModalOpen, setFilterModalOpen] = useState(false);
const [loadingText, setLoadingText] = useState('');
const [showCredentialFields, setShowCredentialFields] = useState(false);
@ -85,7 +87,11 @@ const M3U = ({
account_type: m3uAccount.account_type,
username: m3uAccount.username ?? '',
password: '',
stale_stream_days: m3uAccount.stale_stream_days !== undefined && m3uAccount.stale_stream_days !== null ? m3uAccount.stale_stream_days : 7,
stale_stream_days:
m3uAccount.stale_stream_days !== undefined &&
m3uAccount.stale_stream_days !== null
? m3uAccount.stale_stream_days
: 7,
});
if (m3uAccount.account_type == 'XC') {
@ -145,7 +151,8 @@ const M3U = ({
if (values.account_type != 'XC') {
notifications.show({
title: 'Fetching M3U Groups',
message: 'Configure group filters and auto sync settings once complete.',
message:
'Configure group filters and auto sync settings once complete.',
});
// Don't prompt for group filters, but keeping this here
@ -177,7 +184,10 @@ const M3U = ({
const closeGroupFilter = () => {
setGroupFilterModalOpen(false);
close();
};
const closeFilter = () => {
setFilterModalOpen(false);
};
useEffect(() => {
@ -224,7 +234,12 @@ const M3U = ({
id="account_type"
name="account_type"
label="Account Type"
description={<>Standard for direct M3U URLs, <br />Xtream Codes for panel-based services</>}
description={
<>
Standard for direct M3U URLs, <br />
Xtream Codes for panel-based services
</>
}
data={[
{
value: 'STD',
@ -316,8 +331,13 @@ const M3U = ({
<NumberInput
label="Refresh Interval (hours)"
description={<>How often to automatically refresh M3U data<br />
(0 to disable automatic refreshes)</>}
description={
<>
How often to automatically refresh M3U data
<br />
(0 to disable automatic refreshes)
</>
}
{...form.getInputProps('refresh_interval')}
key={form.key('refresh_interval')}
/>
@ -342,6 +362,13 @@ const M3U = ({
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
{playlist && (
<>
<Button
variant="filled"
size="sm"
onClick={() => setFilterModalOpen(true)}
>
Filters
</Button>
<Button
variant="filled"
// color={theme.custom.colors.buttonPrimary}
@ -382,6 +409,11 @@ const M3U = ({
playlist={playlist}
onClose={closeGroupFilter}
/>
<M3UFilters
isOpen={filterModalOpen}
playlist={playlist}
onClose={closeFilter}
/>
</>
)}
</form>

View file

@ -0,0 +1,126 @@
// Modal.js
import React, { useState, useEffect } from 'react';
import API from '../../api';
import {
TextInput,
Button,
Modal,
Flex,
Select,
PasswordInput,
Group,
Stack,
MultiSelect,
ActionIcon,
Switch,
Box,
} from '@mantine/core';
import { RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import {
M3U_FILTER_TYPES,
USER_LEVELS,
USER_LEVEL_LABELS,
} from '../../constants';
import useAuthStore from '../../store/auth';
import usePlaylistsStore from '../../store/playlists';
const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => {
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const form = useForm({
mode: 'uncontrolled',
initialValues: {
filter_type: 'group',
regex_pattern: '',
exclude: true,
},
validate: (values) => ({}),
});
useEffect(() => {
if (filter) {
form.setValues({
filter_type: filter.filter_type,
regex_pattern: filter.regex_pattern,
exclude: filter.exclude,
});
} else {
form.reset();
}
}, [filter]);
const onSubmit = async () => {
const values = form.getValues();
if (!filter) {
// By default, new rule will go at the end
values.order = m3u.filters.length;
await API.addM3UFilter(m3u.id, values);
} else {
await API.updateM3UFilter(m3u.id, filter.id, values);
}
fetchPlaylist(m3u.id);
form.reset();
onClose();
};
if (!isOpen) {
return <></>;
}
return (
<Modal opened={isOpen} onClose={onClose} title="Filter">
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack gap="xs" style={{ flex: 1 }}>
<Select
label="Field"
description="Specify which property of the stream object this rule will apply to"
data={M3U_FILTER_TYPES}
{...form.getInputProps('filter_type')}
key={form.key('filter_type')}
/>
<TextInput
id="regex_pattern"
name="regex_pattern"
label="Regex Pattern"
description="Regular expression to execute on the value to determine if the filter applies to the item"
{...form.getInputProps('regex_pattern')}
key={form.key('regex_pattern')}
/>
<Group justify="space-between">
<Box>Exclude</Box>
<Switch
id="exclude"
name="exclude"
description="Specify if this is an exclusion or inclusion rule"
key={form.key('exclude')}
{...form.getInputProps('exclude', {
type: 'checkbox',
})}
/>
</Group>
</Stack>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
type="submit"
variant="contained"
disabled={form.submitting}
size="small"
>
Save
</Button>
</Flex>
</form>
</Modal>
);
};
export default M3UFilter;

View file

@ -0,0 +1,327 @@
import React, { useState, useEffect } from 'react';
import API from '../../api';
import M3UProfile from './M3UProfile';
import usePlaylistsStore from '../../store/playlists';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
import {
Card,
Checkbox,
Flex,
Modal,
Button,
Box,
ActionIcon,
Text,
NumberInput,
useMantineTheme,
Center,
Group,
Switch,
Stack,
} from '@mantine/core';
import { GripHorizontal, SquareMinus, SquarePen } from 'lucide-react';
import M3UFilter from './M3UFilter';
import { M3U_FILTER_TYPES } from '../../constants';
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
const RowDragHandleCell = ({ rowId }) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: rowId,
});
return (
<Center>
<ActionIcon
ref={setNodeRef}
{...listeners}
{...attributes}
variant="transparent"
size="xs"
style={{
cursor: 'grab', // this is enough
}}
>
<GripHorizontal color="white" />
</ActionIcon>
</Center>
);
};
// Row Component
const DraggableRow = ({ filter, onDelete }) => {
const theme = useMantineTheme();
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: filter.id,
});
const style = {
transform: CSS.Transform.toString(transform), //let dnd-kit do its thing
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: 'relative',
};
return (
<Box
ref={setNodeRef}
key={filter.id}
spacing="xs"
style={{
...style,
padding: '8px',
paddingBottom: '8px',
border: '1px solid #444',
borderRadius: '8px',
backgroundColor: '#2A2A2E',
flexDirection: 'column',
alignItems: 'stretch',
marginBottom: 5,
}}
>
<Flex gap="sm" justify="space-between" alignItems="middle">
<Group justify="left">
<RowDragHandleCell rowId={filter.id} />
<Text
size="sm"
fw={700}
style={{
color: filter.exclude
? theme.tailwind.red[6]
: theme.tailwind.green[5],
}}
>
{filter.exclude ? 'Exclude' : 'Include'}
</Text>
<Text size="sm">
{
M3U_FILTER_TYPES.find((type) => type.value == filter.filter_type)
.label
}
</Text>
<Text size="sm">matching</Text>
<Text size="sm">
"<code>{filter.regex_pattern}</code>"
</Text>
</Group>
<Group align="flex-end" gap="xs">
<ActionIcon
size="sm"
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editFilter(filter)}
>
<SquarePen size="20" />
</ActionIcon>
<ActionIcon
color={theme.tailwind.red[6]}
onClick={() => onDelete(filter.id)}
size="small"
variant="transparent"
>
<SquareMinus size="20" />
</ActionIcon>
</Group>
</Flex>
</Box>
);
};
const M3UFilters = ({ playlist, isOpen, onClose }) => {
const theme = useMantineTheme();
const [editorOpen, setEditorOpen] = useState(false);
const [filter, setFilter] = useState(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [filterToDelete, setFilterToDelete] = useState(null);
const [filters, setFilters] = useState([]);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
useEffect(() => {
setFilters(playlist.filters || []);
}, [playlist]);
const editFilter = (filter = null) => {
if (filter) {
setFilter(filter);
}
setEditorOpen(true);
};
const onDelete = async (id) => {
if (!playlist || !playlist.id) return;
// Get profile details for the confirmation dialog
const filterObj = playlist.filters.find((p) => p.id === id);
setFilterToDelete(filterObj);
setDeleteTarget(id);
// Skip warning if it's been suppressed
if (isWarningSuppressed('delete-filter')) {
return deleteFilter(id);
}
fetchPlaylist(playlist.id);
setConfirmDeleteOpen(true);
};
const deleteFilter = async (id) => {
if (!playlist || !playlist.id) return;
try {
await API.deleteM3UFilter(playlist.id, id);
setConfirmDeleteOpen(false);
} catch (error) {
console.error('Error deleting profile:', error);
setConfirmDeleteOpen(false);
}
fetchPlaylist(playlist.id);
};
const closeEditor = () => {
setFilter(null);
setEditorOpen(false);
};
const handleDragEnd = async ({ active, over }) => {
if (!over || active.id === over.id) return;
const originalFilters = [...filters];
const oldIndex = filters.findIndex((f) => f.id === active.id);
const newIndex = filters.findIndex((f) => f.id === over.id);
const newFilters = arrayMove(filters, oldIndex, newIndex);
setFilters(newFilters);
// Recalculate and compare order
const updatedFilters = newFilters.map((filter, index) => ({
...filter,
newOrder: index,
}));
// Filter only those whose order actually changed
const changedFilters = updatedFilters.filter((f) => f.order !== f.newOrder);
// Send updates
try {
await Promise.all(
changedFilters.map((f) =>
API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder })
)
);
await fetchPlaylist(playlist.id);
} catch (e) {
setFilters(originalFilters);
}
};
// Don't render if modal is not open, or if playlist data is invalid
if (!isOpen || !playlist || !playlist.id) {
return <></>;
}
return (
<>
<Modal opened={isOpen} onClose={onClose} title="Filters" size="lg">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<SortableContext
items={filters.map(({ id }) => id)}
strategy={verticalListSortingStrategy}
>
{filters.map((filter) => (
<DraggableRow
key={filter.id}
filter={filter}
onDelete={onDelete}
/>
))}
</SortableContext>
</DndContext>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button
variant="contained"
color="primary"
size="small"
onClick={() => editFilter()}
style={{ width: '100%' }}
>
New
</Button>
</Flex>
</Modal>
<M3UFilter
m3u={playlist}
filter={filter}
isOpen={editorOpen}
onClose={closeEditor}
/>
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => deleteFilter(deleteTarget)}
title="Confirm Filter Deletion"
message={
filterToDelete ? (
<div style={{ whiteSpace: 'pre-line' }}>
{`Are you sure you want to delete the following filter?
Type: ${filterToDelete.type}
Patter: ${filterToDelete.regex_pattern}
This action cannot be undone.`}
</div>
) : (
'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;

View file

@ -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' },

View file

@ -19,6 +19,24 @@ 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,
},
}));
} 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 +109,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
}