mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-20 03:03:43 +08:00
feat: Implement granular role-based access control, enhance IPTV stream proxy with header forwarding, and improve IPTV store with per-source channel caching.
This commit is contained in:
@@ -15,7 +15,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
|
||||
interface AccountInfo {
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
role: 'super_admin' | 'admin' | 'viewer';
|
||||
}
|
||||
|
||||
function getAccountList(): AccountInfo[] {
|
||||
@@ -23,7 +23,7 @@ function getAccountList(): AccountInfo[] {
|
||||
|
||||
// Add admin from ADMIN_PASSWORD
|
||||
if (effectiveAdminPassword) {
|
||||
accounts.push({ name: '管理员', role: 'admin' });
|
||||
accounts.push({ name: '超级管理员', role: 'super_admin' });
|
||||
}
|
||||
|
||||
// Add accounts from ACCOUNTS env var
|
||||
@@ -35,7 +35,8 @@ function getAccountList(): AccountInfo[] {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[1].trim();
|
||||
const role = parts[2]?.trim() === 'admin' ? 'admin' : 'viewer';
|
||||
const parsedRole = parts[2]?.trim();
|
||||
const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer';
|
||||
if (name) {
|
||||
accounts.push({ name, role });
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
|
||||
interface AccountEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
role: 'super_admin' | 'admin' | 'viewer';
|
||||
}
|
||||
|
||||
function parseAccounts(): AccountEntry[] {
|
||||
@@ -32,10 +32,11 @@ function parseAccounts(): AccountEntry[] {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
const [password, name, role] = parts;
|
||||
const parsedRole = role?.trim();
|
||||
return {
|
||||
password: password.trim(),
|
||||
name: name.trim(),
|
||||
role: (role?.trim() === 'admin' ? 'admin' : 'viewer') as 'admin' | 'viewer',
|
||||
role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer',
|
||||
};
|
||||
})
|
||||
.filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0);
|
||||
@@ -78,7 +79,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
name: '管理员',
|
||||
role: 'admin',
|
||||
role: 'super_admin',
|
||||
profileId,
|
||||
persistSession: PERSIST_SESSION,
|
||||
});
|
||||
|
||||
@@ -52,12 +52,21 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
|
||||
'Accept': '*/*',
|
||||
},
|
||||
});
|
||||
const parsedUrl = new URL(url);
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
|
||||
'Accept': '*/*',
|
||||
'Referer': `${parsedUrl.protocol}//${parsedUrl.host}/`,
|
||||
'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
};
|
||||
|
||||
// Forward Range header for partial content requests
|
||||
const rangeHeader = request.headers.get('range');
|
||||
if (rangeHeader) {
|
||||
fetchHeaders['Range'] = rangeHeader;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers: fetchHeaders });
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
@@ -96,13 +105,23 @@ export async function GET(request: NextRequest) {
|
||||
const body = response.body;
|
||||
const forwardContentType = contentType || 'video/mp2t';
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': forwardContentType,
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
...corsHeaders,
|
||||
};
|
||||
|
||||
// Forward range-related headers
|
||||
const contentRange = response.headers.get('content-range');
|
||||
if (contentRange) responseHeaders['Content-Range'] = contentRange;
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) responseHeaders['Content-Length'] = contentLength;
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': forwardContentType,
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
...corsHeaders,
|
||||
},
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+15
-11
@@ -10,15 +10,17 @@ import { IPTVSourceManager } from '@/components/iptv/IPTVSourceManager';
|
||||
import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
|
||||
import { IPTVPlayer } from '@/components/iptv/IPTVPlayer';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { AdminGate } from '@/components/AdminGate';
|
||||
import { hasPermission } from '@/lib/store/auth-store';
|
||||
import Link from 'next/link';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
export default function IPTVPage() {
|
||||
const { sources, cachedChannels, cachedGroups, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
|
||||
const { sources, cachedChannels, cachedGroups, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
|
||||
const [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
|
||||
const canManageSources = hasPermission('source_management');
|
||||
|
||||
// Auto-refresh on first load if we have sources but no cached channels
|
||||
useEffect(() => {
|
||||
if (sources.length > 0 && cachedChannels.length === 0 && !isLoading) {
|
||||
@@ -27,7 +29,6 @@ export default function IPTVPage() {
|
||||
}, [sources.length, cachedChannels.length, isLoading, refreshSources]);
|
||||
|
||||
return (
|
||||
<AdminGate>
|
||||
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
|
||||
<div className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
{/* Header */}
|
||||
@@ -54,13 +55,15 @@ export default function IPTVPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowManager(!showManager)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer"
|
||||
>
|
||||
<Icons.Settings size={16} />
|
||||
管理源
|
||||
</button>
|
||||
{canManageSources && (
|
||||
<button
|
||||
onClick={() => setShowManager(!showManager)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer"
|
||||
>
|
||||
<Icons.Settings size={16} />
|
||||
管理源
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +90,8 @@ export default function IPTVPage() {
|
||||
groups={cachedGroups}
|
||||
onSelect={setActiveChannel}
|
||||
activeChannel={activeChannel}
|
||||
channelsBySource={cachedChannelsBySource}
|
||||
sources={sources}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -102,6 +107,5 @@ export default function IPTVPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-32
@@ -11,7 +11,8 @@ import { AccountSettings } from '@/components/settings/AccountSettings';
|
||||
import { DisplaySettings } from '@/components/settings/DisplaySettings';
|
||||
import { PlayerSettings } from '@/components/settings/PlayerSettings';
|
||||
import { SettingsHeader } from '@/components/settings/SettingsHeader';
|
||||
import { AdminGate } from '@/components/AdminGate';
|
||||
import { PermissionGate } from '@/components/PermissionGate';
|
||||
import { hasPermission } from '@/lib/store/auth-store';
|
||||
import { useSettingsPage } from './hooks/useSettingsPage';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -64,7 +65,6 @@ export default function SettingsPage() {
|
||||
} = useSettingsPage();
|
||||
|
||||
return (
|
||||
<AdminGate>
|
||||
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
@@ -74,20 +74,23 @@ export default function SettingsPage() {
|
||||
<AccountSettings />
|
||||
|
||||
{/* Player Settings */}
|
||||
<PlayerSettings
|
||||
fullscreenType={fullscreenType}
|
||||
onFullscreenTypeChange={handleFullscreenTypeChange}
|
||||
proxyMode={proxyMode}
|
||||
onProxyModeChange={handleProxyModeChange}
|
||||
danmakuApiUrl={danmakuApiUrl}
|
||||
onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
onDanmakuOpacityChange={handleDanmakuOpacityChange}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
onDanmakuFontSizeChange={handleDanmakuFontSizeChange}
|
||||
danmakuDisplayArea={danmakuDisplayArea}
|
||||
onDanmakuDisplayAreaChange={handleDanmakuDisplayAreaChange}
|
||||
/>
|
||||
<PermissionGate permission="player_settings">
|
||||
<PlayerSettings
|
||||
fullscreenType={fullscreenType}
|
||||
onFullscreenTypeChange={handleFullscreenTypeChange}
|
||||
proxyMode={proxyMode}
|
||||
onProxyModeChange={handleProxyModeChange}
|
||||
danmakuApiUrl={danmakuApiUrl}
|
||||
onDanmakuApiUrlChange={handleDanmakuApiUrlChange}
|
||||
danmakuOpacity={danmakuOpacity}
|
||||
onDanmakuOpacityChange={handleDanmakuOpacityChange}
|
||||
danmakuFontSize={danmakuFontSize}
|
||||
onDanmakuFontSizeChange={handleDanmakuFontSizeChange}
|
||||
danmakuDisplayArea={danmakuDisplayArea}
|
||||
onDanmakuDisplayAreaChange={handleDanmakuDisplayAreaChange}
|
||||
showDanmakuApi={hasPermission('danmaku_api')}
|
||||
/>
|
||||
</PermissionGate>
|
||||
|
||||
{/* Display Settings */}
|
||||
<DisplaySettings
|
||||
@@ -100,16 +103,18 @@ export default function SettingsPage() {
|
||||
/>
|
||||
|
||||
{/* Source Management */}
|
||||
<SourceSettings
|
||||
sources={sources}
|
||||
onSourcesChange={handleSourcesChange}
|
||||
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
|
||||
onAddSource={() => {
|
||||
setEditingSource(null);
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
onEditSource={handleEditSource}
|
||||
/>
|
||||
<PermissionGate permission="source_management">
|
||||
<SourceSettings
|
||||
sources={sources}
|
||||
onSourcesChange={handleSourcesChange}
|
||||
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
|
||||
onAddSource={() => {
|
||||
setEditingSource(null);
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
onEditSource={handleEditSource}
|
||||
/>
|
||||
</PermissionGate>
|
||||
|
||||
{/* Sort Options */}
|
||||
<SortSettings
|
||||
@@ -118,11 +123,13 @@ export default function SettingsPage() {
|
||||
/>
|
||||
|
||||
{/* Data Management */}
|
||||
<DataSettings
|
||||
onExport={() => setIsExportModalOpen(true)}
|
||||
onImport={() => setIsImportModalOpen(true)}
|
||||
onReset={() => setIsResetDialogOpen(true)}
|
||||
/>
|
||||
<PermissionGate permission="data_management">
|
||||
<DataSettings
|
||||
onExport={() => setIsExportModalOpen(true)}
|
||||
onImport={() => setIsImportModalOpen(true)}
|
||||
onReset={() => setIsResetDialogOpen(true)}
|
||||
/>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
@@ -175,6 +182,5 @@ export default function SettingsPage() {
|
||||
dangerous
|
||||
/>
|
||||
</div>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { hasPermission, type Permission } from '@/lib/store/auth-store';
|
||||
|
||||
interface PermissionGateProps {
|
||||
permission: Permission;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PermissionGate({ permission, children, fallback = null }: PermissionGateProps) {
|
||||
if (!hasPermission(permission)) return <>{fallback}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function SortableTag({
|
||||
>
|
||||
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
|
||||
<button
|
||||
onClick={() => !showTagManager && onTagSelect(tag.id)}
|
||||
onClick={() => onTagSelect(tag.id)}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
|
||||
${selectedTag === tag.id
|
||||
|
||||
@@ -7,7 +7,11 @@ const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' };
|
||||
const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_';
|
||||
|
||||
export function useTagManager() {
|
||||
const [contentType, setContentType] = useState<'movie' | 'tv'>('movie');
|
||||
const [contentType, setContentType] = useState<'movie' | 'tv'>(() => {
|
||||
if (typeof window === 'undefined') return 'movie';
|
||||
const saved = localStorage.getItem('kvideo_default_content_type');
|
||||
return saved === 'tv' ? 'tv' : 'movie';
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value);
|
||||
const [tags, setTags] = useState<any[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
@@ -15,6 +19,11 @@ export function useTagManager() {
|
||||
const [showTagManager, setShowTagManager] = useState(false);
|
||||
const [justAddedTag, setJustAddedTag] = useState(false);
|
||||
|
||||
// Persist content type preference
|
||||
useEffect(() => {
|
||||
localStorage.setItem('kvideo_default_content_type', contentType);
|
||||
}, [contentType]);
|
||||
|
||||
const storageKey = `${STORAGE_KEY_PREFIX}${contentType}`;
|
||||
|
||||
// Load custom tags or fetch from Douban
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
import type { IPTVSource } from '@/lib/store/iptv-store';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
@@ -16,15 +17,30 @@ interface IPTVChannelGridProps {
|
||||
groups: string[];
|
||||
onSelect: (channel: M3UChannel) => void;
|
||||
activeChannel?: M3UChannel | null;
|
||||
channelsBySource?: Record<string, { channels: M3UChannel[]; groups: string[] }>;
|
||||
sources?: IPTVSource[];
|
||||
}
|
||||
|
||||
export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: IPTVChannelGridProps) {
|
||||
export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, channelsBySource, sources }: IPTVChannelGridProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
const hasMultipleSources = sources && sources.length > 1 && channelsBySource;
|
||||
|
||||
// Get effective channels and groups based on selected source
|
||||
const { effectiveChannels, effectiveGroups } = useMemo(() => {
|
||||
if (!hasMultipleSources || !selectedSourceId) {
|
||||
return { effectiveChannels: channels, effectiveGroups: groups };
|
||||
}
|
||||
const sourceData = channelsBySource[selectedSourceId];
|
||||
if (!sourceData) return { effectiveChannels: channels, effectiveGroups: groups };
|
||||
return { effectiveChannels: sourceData.channels, effectiveGroups: sourceData.groups };
|
||||
}, [channels, groups, hasMultipleSources, selectedSourceId, channelsBySource]);
|
||||
|
||||
const filteredChannels = useMemo(() => {
|
||||
let result = channels;
|
||||
let result = effectiveChannels;
|
||||
|
||||
if (selectedGroup) {
|
||||
result = result.filter((c) => c.group === selectedGroup);
|
||||
@@ -36,16 +52,23 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [channels, selectedGroup, search]);
|
||||
}, [effectiveChannels, selectedGroup, search]);
|
||||
|
||||
// Reset visible count when filter changes
|
||||
const filterKey = `${selectedGroup}-${search}`;
|
||||
const filterKey = `${selectedSourceId}-${selectedGroup}-${search}`;
|
||||
const [lastFilterKey, setLastFilterKey] = useState(filterKey);
|
||||
if (filterKey !== lastFilterKey) {
|
||||
setLastFilterKey(filterKey);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}
|
||||
|
||||
// Reset group when source changes
|
||||
const [lastSourceId, setLastSourceId] = useState(selectedSourceId);
|
||||
if (selectedSourceId !== lastSourceId) {
|
||||
setLastSourceId(selectedSourceId);
|
||||
setSelectedGroup(null);
|
||||
}
|
||||
|
||||
const visibleChannels = filteredChannels.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < filteredChannels.length;
|
||||
|
||||
@@ -77,13 +100,46 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
|
||||
{/* Channel count */}
|
||||
<div className="text-xs text-[var(--text-color-secondary)]">
|
||||
{filteredChannels.length === channels.length
|
||||
? `共 ${channels.length} 个频道`
|
||||
: `${filteredChannels.length} / ${channels.length} 个频道`}
|
||||
{filteredChannels.length === effectiveChannels.length
|
||||
? `共 ${effectiveChannels.length} 个频道`
|
||||
: `${filteredChannels.length} / ${effectiveChannels.length} 个频道`}
|
||||
</div>
|
||||
|
||||
{/* Source Tabs (only when multiple sources) */}
|
||||
{hasMultipleSources && sources && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedSourceId(null)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedSourceId === null
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{sources.map((source) => {
|
||||
const sourceData = channelsBySource![source.id];
|
||||
if (!sourceData) return null;
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
onClick={() => setSelectedSourceId(source.id === selectedSourceId ? null : source.id)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
|
||||
selectedSourceId === source.id
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
{source.name} ({sourceData.channels.length})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Tabs */}
|
||||
{groups.length > 0 && (
|
||||
{effectiveGroups.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setSelectedGroup(null)}
|
||||
@@ -93,10 +149,10 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I
|
||||
: 'bg-[var(--glass-bg)] border-[var(--glass-border)] text-[var(--text-color)] hover:border-[var(--accent-color)]/30'
|
||||
}`}
|
||||
>
|
||||
全部 ({channels.length})
|
||||
全部 ({effectiveChannels.length})
|
||||
</button>
|
||||
{groups.map((group) => {
|
||||
const count = channels.filter((c) => c.group === group).length;
|
||||
{effectiveGroups.map((group) => {
|
||||
const count = effectiveChannels.filter((c) => c.group === group).length;
|
||||
return (
|
||||
<button
|
||||
key={group}
|
||||
|
||||
+100
-42
@@ -6,11 +6,23 @@
|
||||
* Routes streams through proxy to avoid CORS when direct access fails.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
|
||||
const HLS_LIVE_CONFIG: Partial<Hls['config']> = {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
liveDurationInfinity: true,
|
||||
manifestLoadingTimeOut: 10000,
|
||||
manifestLoadingMaxRetry: 2,
|
||||
levelLoadingTimeOut: 10000,
|
||||
fragLoadingTimeOut: 15000,
|
||||
};
|
||||
|
||||
const LOADING_TIMEOUT_MS = 20000;
|
||||
|
||||
interface IPTVPlayerProps {
|
||||
channel: M3UChannel;
|
||||
onClose: () => void;
|
||||
@@ -37,9 +49,11 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeChannelRef = useRef<HTMLButtonElement>(null);
|
||||
const controlsTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const loadingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSidebar, setShowSidebar] = useState(false);
|
||||
const [sidebarSearch, setSidebarSearch] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
@@ -140,16 +154,43 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
loadingTimeoutRef.current = undefined;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
|
||||
const proxiedUrl = getProxiedUrl(url);
|
||||
|
||||
// Global loading timeout
|
||||
let loadingResolved = false;
|
||||
const markLoaded = () => {
|
||||
if (loadingResolved) return;
|
||||
loadingResolved = true;
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
loadingTimeoutRef.current = undefined;
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
const markError = (msg: string) => {
|
||||
if (loadingResolved) return;
|
||||
loadingResolved = true;
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
loadingTimeoutRef.current = undefined;
|
||||
}
|
||||
setIsLoading(false);
|
||||
setError(msg);
|
||||
};
|
||||
|
||||
loadingTimeoutRef.current = setTimeout(() => {
|
||||
markError('加载超时,请尝试其他线路或频道');
|
||||
}, LOADING_TIMEOUT_MS);
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
});
|
||||
const hls = new Hls(HLS_LIVE_CONFIG);
|
||||
hlsRef.current = hls;
|
||||
|
||||
let triedProxy = false;
|
||||
@@ -157,16 +198,15 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
|
||||
const tryDirectVideo = (directUrl: string) => {
|
||||
if (triedDirect) {
|
||||
setIsLoading(false);
|
||||
setError('播放错误,请尝试其他线路或频道');
|
||||
markError('播放错误,请尝试其他线路或频道');
|
||||
return;
|
||||
}
|
||||
triedDirect = true;
|
||||
const vid = videoRef.current;
|
||||
if (!vid) return;
|
||||
vid.src = directUrl;
|
||||
vid.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
vid.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
vid.play().catch(() => {});
|
||||
}, { once: true });
|
||||
vid.addEventListener('error', () => {
|
||||
@@ -175,17 +215,15 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
const vid2 = videoRef.current;
|
||||
if (!vid2) return;
|
||||
vid2.src = proxiedUrl;
|
||||
vid2.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
vid2.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
vid2.play().catch(() => {});
|
||||
}, { once: true });
|
||||
vid2.addEventListener('error', () => {
|
||||
setIsLoading(false);
|
||||
setError('播放错误,请尝试其他线路或频道');
|
||||
markError('播放错误,请尝试其他线路或频道');
|
||||
}, { once: true });
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setError('播放错误,请尝试其他线路或频道');
|
||||
markError('播放错误,请尝试其他线路或频道');
|
||||
}
|
||||
}, { once: true });
|
||||
};
|
||||
@@ -197,15 +235,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
}
|
||||
triedProxy = true;
|
||||
hls.destroy();
|
||||
const hlsProxy = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
});
|
||||
const hlsProxy = new Hls(HLS_LIVE_CONFIG);
|
||||
hlsRef.current = hlsProxy;
|
||||
hlsProxy.loadSource(proxiedUrl);
|
||||
hlsProxy.attachMedia(video);
|
||||
hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsLoading(false);
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
hlsProxy.on(Hls.Events.ERROR, (_, data) => {
|
||||
@@ -225,7 +260,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setIsLoading(false);
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
@@ -242,37 +277,35 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS (Safari/iOS)
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
video.src = proxiedUrl;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
setIsLoading(false);
|
||||
setError('播放错误');
|
||||
markError('播放错误');
|
||||
}, { once: true });
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Direct video fallback
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
video.src = proxiedUrl;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setIsLoading(false);
|
||||
video.addEventListener('canplay', () => {
|
||||
markLoaded();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
video.addEventListener('error', () => {
|
||||
setIsLoading(false);
|
||||
setError('播放错误,请尝试其他频道');
|
||||
markError('播放错误,请尝试其他频道');
|
||||
}, { once: true });
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -286,6 +319,10 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (loadingTimeoutRef.current) {
|
||||
clearTimeout(loadingTimeoutRef.current);
|
||||
loadingTimeoutRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [currentUrl, loadChannel]);
|
||||
|
||||
@@ -335,6 +372,12 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
|
||||
const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2;
|
||||
|
||||
const filteredSidebarChannels = useMemo(() => {
|
||||
if (!sidebarSearch.trim()) return channels;
|
||||
const q = sidebarSearch.toLowerCase().trim();
|
||||
return channels.filter(ch => ch.name.toLowerCase().includes(q));
|
||||
}, [channels, sidebarSearch]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -526,17 +569,32 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div data-sidebar className="w-72 bg-[#111] border-l border-white/10 overflow-y-auto flex-shrink-0">
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between sticky top-0 bg-[#111] z-10">
|
||||
<h3 className="text-white text-sm font-medium">频道列表</h3>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowSidebar(false); }}
|
||||
className="text-white/50 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
</button>
|
||||
<div className="sticky top-0 bg-[#111] z-10">
|
||||
<div className="p-3 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-white text-sm font-medium">频道列表</h3>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowSidebar(false); }}
|
||||
className="text-white/50 hover:text-white cursor-pointer"
|
||||
>
|
||||
<Icons.X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-white/10">
|
||||
<div className="relative">
|
||||
<Icons.Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-white/30" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索频道..."
|
||||
value={sidebarSearch}
|
||||
onChange={(e) => setSidebarSearch(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full pl-7 pr-2 py-1.5 bg-white/5 border border-white/10 rounded-lg text-xs text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
{channels.map((ch, i) => {
|
||||
{filteredSidebarChannels.map((ch, i) => {
|
||||
const isActive = ch.name === channel.name && ch.url === channel.url;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSession, clearSession } from '@/lib/store/auth-store';
|
||||
import { getSession, clearSession, hasPermission, type Role } from '@/lib/store/auth-store';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LogOut, Shield, Info } from 'lucide-react';
|
||||
|
||||
interface AccountInfo {
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
role: Role;
|
||||
}
|
||||
|
||||
interface ConfigEntry {
|
||||
password: string;
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
@@ -50,7 +50,7 @@ export function AccountSettings() {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const isAdmin = session?.role === 'admin';
|
||||
const canManageAccounts = hasPermission('account_management');
|
||||
|
||||
// Config generator helpers
|
||||
const addConfigEntry = () => {
|
||||
@@ -70,7 +70,7 @@ export function AccountSettings() {
|
||||
const generateAccountsString = () => {
|
||||
return configEntries
|
||||
.filter(e => e.password.trim() && e.name.trim())
|
||||
.map(e => `${e.password}:${e.name}${e.role === 'admin' ? ':admin' : ''}`)
|
||||
.map(e => `${e.password}:${e.name}${e.role !== 'viewer' ? ':' + e.role : ''}`)
|
||||
.join(',');
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export function AccountSettings() {
|
||||
// Filter out removed accounts and the standalone admin password account
|
||||
const existingEntries: ConfigEntry[] = accounts
|
||||
.filter((_, i) => !removedAccounts.has(i))
|
||||
.filter(a => !(a.name === '管理员' && hasAdminPassword))
|
||||
.filter(a => !(a.name === '超级管理员' && hasAdminPassword))
|
||||
.map(a => ({
|
||||
password: '',
|
||||
name: a.name,
|
||||
@@ -124,9 +124,9 @@ export function AccountSettings() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-color)]">{session.name}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Shield size={12} className={session.role === 'admin' ? 'text-[var(--accent-color)]' : 'text-[var(--text-color-secondary)]'} />
|
||||
<Shield size={12} className={session.role === 'super_admin' || session.role === 'admin' ? 'text-[var(--accent-color)]' : 'text-[var(--text-color-secondary)]'} />
|
||||
<span className="text-xs text-[var(--text-color-secondary)]">
|
||||
{session.role === 'admin' ? '管理员' : '观众'}
|
||||
{session.role === 'super_admin' ? '超级管理员' : session.role === 'admin' ? '管理员' : '观众'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,8 +144,8 @@ export function AccountSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account List (Admin only) */}
|
||||
{isAdmin && visibleAccounts.length > 0 && (
|
||||
{/* Account List (Account managers only) */}
|
||||
{canManageAccounts && visibleAccounts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] mb-3 flex items-center gap-2">
|
||||
<Icons.Users size={16} className="text-[var(--accent-color)]" />
|
||||
@@ -167,11 +167,11 @@ export function AccountSettings() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-[var(--radius-full)] ${
|
||||
account.role === 'admin'
|
||||
account.role === 'super_admin' || account.role === 'admin'
|
||||
? 'bg-[var(--accent-color)]/10 text-[var(--accent-color)]'
|
||||
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] border border-[var(--glass-border)]'
|
||||
}`}>
|
||||
{account.role === 'admin' ? '管理员' : '观众'}
|
||||
{account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAccount(index)}
|
||||
@@ -211,8 +211,8 @@ export function AccountSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Generator (Admin only) */}
|
||||
{isAdmin && (
|
||||
{/* Config Generator (Account managers only) */}
|
||||
{canManageAccounts && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--text-color)] flex items-center gap-2">
|
||||
@@ -276,6 +276,7 @@ export function AccountSettings() {
|
||||
>
|
||||
<option value="viewer">观众</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="super_admin">超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface PlayerSettingsProps {
|
||||
onDanmakuFontSizeChange: (value: number) => void;
|
||||
danmakuDisplayArea: number;
|
||||
onDanmakuDisplayAreaChange: (value: number) => void;
|
||||
showDanmakuApi?: boolean;
|
||||
}
|
||||
|
||||
const DANMAKU_FONT_SIZES = [14, 18, 20, 24, 28];
|
||||
@@ -44,6 +45,7 @@ export function PlayerSettings({
|
||||
onDanmakuFontSizeChange,
|
||||
danmakuDisplayArea,
|
||||
onDanmakuDisplayAreaChange,
|
||||
showDanmakuApi = true,
|
||||
}: PlayerSettingsProps) {
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6 mb-6">
|
||||
@@ -142,6 +144,7 @@ export function PlayerSettings({
|
||||
|
||||
{/* API URL */}
|
||||
<div className="space-y-4">
|
||||
{showDanmakuApi && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
|
||||
API 地址
|
||||
@@ -157,6 +160,7 @@ export function PlayerSettings({
|
||||
兼容 <a href="https://github.com/huangxd-/danmu_api" target="_blank" rel="noopener noreferrer" className="text-[var(--accent-color)] hover:underline">danmu_api</a> 格式的弹幕聚合服务
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opacity */}
|
||||
<div>
|
||||
|
||||
+32
-2
@@ -3,10 +3,27 @@
|
||||
* NOT Zustand — needs to be synchronous at import time for store key generation
|
||||
*/
|
||||
|
||||
export type Role = 'super_admin' | 'admin' | 'viewer';
|
||||
|
||||
export type Permission =
|
||||
| 'source_management'
|
||||
| 'account_management'
|
||||
| 'danmaku_api'
|
||||
| 'data_management'
|
||||
| 'player_settings'
|
||||
| 'danmaku_appearance'
|
||||
| 'view_settings';
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings'],
|
||||
admin: ['player_settings', 'danmaku_appearance', 'view_settings'],
|
||||
viewer: ['view_settings'],
|
||||
};
|
||||
|
||||
export interface AuthSession {
|
||||
profileId: string;
|
||||
name: string;
|
||||
role: 'admin' | 'viewer';
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'kvideo-session';
|
||||
@@ -50,7 +67,20 @@ export function clearSession(): void {
|
||||
export function isAdmin(): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
return session.role === 'admin';
|
||||
return session.role === 'admin' || session.role === 'super_admin';
|
||||
}
|
||||
|
||||
export function hasPermission(permission: Permission): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
return ROLE_PERMISSIONS[session.role]?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
export function hasRole(minimumRole: Role): boolean {
|
||||
const session = getSession();
|
||||
if (!session) return true; // No auth configured = full access
|
||||
const hierarchy: Role[] = ['viewer', 'admin', 'super_admin'];
|
||||
return hierarchy.indexOf(session.role) >= hierarchy.indexOf(minimumRole);
|
||||
}
|
||||
|
||||
export function getProfileId(): string {
|
||||
|
||||
+28
-2
@@ -17,6 +17,7 @@ interface IPTVState {
|
||||
sources: IPTVSource[];
|
||||
cachedChannels: M3UChannel[];
|
||||
cachedGroups: string[];
|
||||
cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }>;
|
||||
lastRefreshed: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -58,6 +59,7 @@ export const useIPTVStore = create<IPTVStore>()(
|
||||
sources: [],
|
||||
cachedChannels: [],
|
||||
cachedGroups: [],
|
||||
cachedChannelsBySource: {},
|
||||
lastRefreshed: 0,
|
||||
isLoading: false,
|
||||
|
||||
@@ -85,7 +87,7 @@ export const useIPTVStore = create<IPTVStore>()(
|
||||
refreshSources: async () => {
|
||||
const { sources } = get();
|
||||
if (sources.length === 0) {
|
||||
set({ cachedChannels: [], cachedGroups: [], lastRefreshed: Date.now() });
|
||||
set({ cachedChannels: [], cachedGroups: [], cachedChannelsBySource: {}, lastRefreshed: Date.now() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +96,8 @@ export const useIPTVStore = create<IPTVStore>()(
|
||||
try {
|
||||
const allChannels: M3UChannel[] = [];
|
||||
const allGroups = new Set<string>();
|
||||
const channelsBySourceRaw: Record<string, M3UChannel[]> = {};
|
||||
const groupsBySource: Record<string, Set<string>> = {};
|
||||
|
||||
const tasks = sources.map((source) => async () => {
|
||||
try {
|
||||
@@ -101,8 +105,17 @@ export const useIPTVStore = create<IPTVStore>()(
|
||||
if (!res.ok) return;
|
||||
const text = await res.text();
|
||||
const playlist = parseM3U(text);
|
||||
allChannels.push(...playlist.channels);
|
||||
// Tag channels with source info
|
||||
const tagged = playlist.channels.map(ch => ({
|
||||
...ch,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
}));
|
||||
allChannels.push(...tagged);
|
||||
playlist.groups.forEach((g) => allGroups.add(g));
|
||||
// Track per-source
|
||||
channelsBySourceRaw[source.id] = tagged;
|
||||
groupsBySource[source.id] = new Set(playlist.groups);
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch IPTV source: ${source.name}`, e);
|
||||
}
|
||||
@@ -113,9 +126,22 @@ export const useIPTVStore = create<IPTVStore>()(
|
||||
// Group channels with the same name into multi-route entries
|
||||
const grouped = groupChannelsByName(allChannels);
|
||||
|
||||
// Build per-source grouped data
|
||||
const cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }> = {};
|
||||
for (const source of sources) {
|
||||
const raw = channelsBySourceRaw[source.id];
|
||||
if (raw) {
|
||||
cachedChannelsBySource[source.id] = {
|
||||
channels: groupChannelsByName(raw),
|
||||
groups: Array.from(groupsBySource[source.id] || []).sort(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
cachedChannels: grouped,
|
||||
cachedGroups: Array.from(allGroups).sort(),
|
||||
cachedChannelsBySource,
|
||||
lastRefreshed: Date.now(),
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface M3UChannel {
|
||||
tvgId?: string;
|
||||
tvgName?: string;
|
||||
routes?: string[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export interface M3UPlaylist {
|
||||
@@ -86,7 +88,7 @@ export function groupChannelsByName(channels: M3UChannel[]): M3UChannel[] {
|
||||
const groups = new Map<string, M3UChannel>();
|
||||
|
||||
for (const ch of channels) {
|
||||
const key = ch.name.toLowerCase().trim();
|
||||
const key = `${ch.sourceId || ''}::${ch.name.toLowerCase().trim()}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
if (!existing.routes) {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.0",
|
||||
"version": "4.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kvideo",
|
||||
"version": "4.4.0",
|
||||
"version": "4.4.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kvideo",
|
||||
"version": "4.4.0",
|
||||
"version": "4.4.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user