diff --git a/app/api/auth/accounts/route.ts b/app/api/auth/accounts/route.ts index 535509e..b18599d 100644 --- a/app/api/auth/accounts/route.ts +++ b/app/api/auth/accounts/route.ts @@ -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 }); } diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index f831e8f..8220e43 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -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, }); diff --git a/app/api/iptv/stream/route.ts b/app/api/iptv/stream/route.ts index f0bb548..23e7fbd 100644 --- a/app/api/iptv/stream/route.ts +++ b/app/api/iptv/stream/route.ts @@ -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 = { + '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 = { + '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) { diff --git a/app/iptv/page.tsx b/app/iptv/page.tsx index 8837402..933a531 100644 --- a/app/iptv/page.tsx +++ b/app/iptv/page.tsx @@ -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(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 ( -
{/* Header */} @@ -54,13 +55,15 @@ export default function IPTVPage() {
- + {canManageSources && ( + + )} @@ -87,6 +90,8 @@ export default function IPTVPage() { groups={cachedGroups} onSelect={setActiveChannel} activeChannel={activeChannel} + channelsBySource={cachedChannelsBySource} + sources={sources} /> )} @@ -102,6 +107,5 @@ export default function IPTVPage() { /> )} -
); } diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ccf7a54..99d9ca0 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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 ( -
{/* Header */} @@ -74,20 +74,23 @@ export default function SettingsPage() { {/* Player Settings */} - + + + {/* Display Settings */} {/* Source Management */} - setIsRestoreDefaultsDialogOpen(true)} - onAddSource={() => { - setEditingSource(null); - setIsAddModalOpen(true); - }} - onEditSource={handleEditSource} - /> + + setIsRestoreDefaultsDialogOpen(true)} + onAddSource={() => { + setEditingSource(null); + setIsAddModalOpen(true); + }} + onEditSource={handleEditSource} + /> + {/* Sort Options */} {/* Data Management */} - setIsExportModalOpen(true)} - onImport={() => setIsImportModalOpen(true)} - onReset={() => setIsResetDialogOpen(true)} - /> + + setIsExportModalOpen(true)} + onImport={() => setIsImportModalOpen(true)} + onReset={() => setIsResetDialogOpen(true)} + /> +
{/* Modals */} @@ -175,6 +182,5 @@ export default function SettingsPage() { dangerous />
-
); } diff --git a/components/PermissionGate.tsx b/components/PermissionGate.tsx new file mode 100644 index 0000000..e85da65 --- /dev/null +++ b/components/PermissionGate.tsx @@ -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}; +} diff --git a/components/home/SortableTag.tsx b/components/home/SortableTag.tsx index 9d1fd5b..cade1ed 100644 --- a/components/home/SortableTag.tsx +++ b/components/home/SortableTag.tsx @@ -51,7 +51,7 @@ export function SortableTag({ >
+ {sources.map((source) => { + const sourceData = channelsBySource![source.id]; + if (!sourceData) return null; + return ( + + ); + })} +
+ )} + {/* Group Tabs */} - {groups.length > 0 && ( + {effectiveGroups.length > 0 && (
- {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 ( +
+
+

频道列表

+ +
+
+
+ + 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" + /> +
+
- {channels.map((ch, i) => { + {filteredSidebarChannels.map((ch, i) => { const isActive = ch.name === channel.name && ch.url === channel.url; return (
)} - {/* Account List (Admin only) */} - {isAdmin && visibleAccounts.length > 0 && ( + {/* Account List (Account managers only) */} + {canManageAccounts && visibleAccounts.length > 0 && (

@@ -167,11 +167,11 @@ export function AccountSettings() {

- {account.role === 'admin' ? '管理员' : '观众'} + {account.role === 'super_admin' ? '超级管理员' : account.role === 'admin' ? '管理员' : '观众'}
)} - {/* Config Generator (Admin only) */} - {isAdmin && ( + {/* Config Generator (Account managers only) */} + {canManageAccounts && (

@@ -276,6 +276,7 @@ export function AccountSettings() { > +

diff --git a/components/settings/PlayerSettings.tsx b/components/settings/PlayerSettings.tsx index 8f590a4..9a51d4c 100644 --- a/components/settings/PlayerSettings.tsx +++ b/components/settings/PlayerSettings.tsx @@ -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 (
@@ -142,6 +144,7 @@ export function PlayerSettings({ {/* API URL */}
+ {showDanmakuApi && (
+ )} {/* Opacity */}
diff --git a/lib/store/auth-store.ts b/lib/store/auth-store.ts index 991bfc8..5401e1f 100644 --- a/lib/store/auth-store.ts +++ b/lib/store/auth-store.ts @@ -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 = { + 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 { diff --git a/lib/store/iptv-store.ts b/lib/store/iptv-store.ts index 6805c13..af46d45 100644 --- a/lib/store/iptv-store.ts +++ b/lib/store/iptv-store.ts @@ -17,6 +17,7 @@ interface IPTVState { sources: IPTVSource[]; cachedChannels: M3UChannel[]; cachedGroups: string[]; + cachedChannelsBySource: Record; lastRefreshed: number; isLoading: boolean; } @@ -58,6 +59,7 @@ export const useIPTVStore = create()( sources: [], cachedChannels: [], cachedGroups: [], + cachedChannelsBySource: {}, lastRefreshed: 0, isLoading: false, @@ -85,7 +87,7 @@ export const useIPTVStore = create()( 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()( try { const allChannels: M3UChannel[] = []; const allGroups = new Set(); + const channelsBySourceRaw: Record = {}; + const groupsBySource: Record> = {}; const tasks = sources.map((source) => async () => { try { @@ -101,8 +105,17 @@ export const useIPTVStore = create()( 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()( // Group channels with the same name into multi-route entries const grouped = groupChannelsByName(allChannels); + // Build per-source grouped data + const cachedChannelsBySource: Record = {}; + 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, }); diff --git a/lib/utils/m3u-parser.ts b/lib/utils/m3u-parser.ts index 6821466..4d3a567 100644 --- a/lib/utils/m3u-parser.ts +++ b/lib/utils/m3u-parser.ts @@ -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(); 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) { diff --git a/package-lock.json b/package-lock.json index c934ac7..10ee852 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9daf0bb..a8483ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kvideo", - "version": "4.4.0", + "version": "4.4.1", "private": true, "scripts": { "dev": "next dev",