Fix issue #72 follow-ups and IPTV source handling

This commit is contained in:
kuekhaoyang
2026-03-26 14:16:58 +08:00
parent 35f7f4d83a
commit 91a89e8bf2
23 changed files with 453 additions and 154 deletions
+14 -1
View File
@@ -9,15 +9,28 @@ export const runtime = 'edge';
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url');
const customUa = request.nextUrl.searchParams.get('ua');
const customReferer = request.nextUrl.searchParams.get('referer');
if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
}
try {
const parsedUrl = new URL(url);
let refererOrigin = `${parsedUrl.protocol}//${parsedUrl.host}`;
if (customReferer) {
try {
refererOrigin = new URL(customReferer).origin;
} catch {
refererOrigin = `${parsedUrl.protocol}//${parsedUrl.host}`;
}
}
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)',
...(customReferer ? { 'Referer': customReferer } : {}),
'Origin': refererOrigin,
},
});
+34 -11
View File
@@ -4,7 +4,7 @@
* IPTV Page - Live TV channel viewer with M3U source management
*/
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useIPTVStore } from '@/lib/store/iptv-store';
import { IPTVSourceManager } from '@/components/iptv/IPTVSourceManager';
import { IPTVChannelGrid } from '@/components/iptv/IPTVChannelGrid';
@@ -15,12 +15,35 @@ import Link from 'next/link';
import type { M3UChannel } from '@/lib/utils/m3u-parser';
export default function IPTVPage() {
const { sources, cachedChannels, cachedGroups, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
const { sources, cachedChannels, cachedChannelsBySource, refreshSources, isLoading, lastRefreshed } = useIPTVStore();
const [activeChannel, setActiveChannel] = useState<M3UChannel | null>(null);
const [showManager, setShowManager] = useState(false);
const canManageSources = hasPermission('source_management');
const canManageSources = hasPermission('iptv_source_management');
const canAccessIPTV = hasPermission('iptv_access');
const canUseBuiltinSources = hasPermission('iptv_builtin_sources');
const visibleSources = useMemo(
() => sources.filter((source) => canUseBuiltinSources || source.kind !== 'builtin'),
[sources, canUseBuiltinSources]
);
const visibleSourceIds = useMemo(() => new Set(visibleSources.map((source) => source.id)), [visibleSources]);
const visibleChannels = useMemo(
() => cachedChannels.filter((channel) => !channel.sourceId || visibleSourceIds.has(channel.sourceId)),
[cachedChannels, visibleSourceIds]
);
const visibleGroups = useMemo(
() => Array.from(new Set(visibleChannels.map((channel) => channel.group).filter(Boolean))).sort() as string[],
[visibleChannels]
);
const visibleChannelsBySource = useMemo(
() =>
Object.fromEntries(
visibleSources
.map((source) => [source.id, cachedChannelsBySource[source.id]])
.filter(([, data]) => !!data)
),
[visibleSources, cachedChannelsBySource]
);
// If auth is configured and user doesn't have iptv_access, show access denied
if (!canAccessIPTV && getSession()) {
@@ -65,7 +88,7 @@ export default function IPTVPage() {
</h1>
<p className="text-sm text-[var(--text-color-secondary)]">
{cachedChannels.length > 0 ? `${cachedChannels.length} 个频道` : 'IPTV 直播频道'}
{visibleChannels.length > 0 ? `${visibleChannels.length} 个频道` : 'IPTV 直播频道'}
</p>
</div>
</div>
@@ -101,12 +124,12 @@ export default function IPTVPage() {
{!isLoading && (
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<IPTVChannelGrid
channels={cachedChannels}
groups={cachedGroups}
channels={visibleChannels}
groups={visibleGroups}
onSelect={setActiveChannel}
activeChannel={activeChannel}
channelsBySource={cachedChannelsBySource}
sources={sources}
channelsBySource={visibleChannelsBySource}
sources={visibleSources}
/>
</div>
)}
@@ -117,10 +140,10 @@ export default function IPTVPage() {
<IPTVPlayer
channel={activeChannel}
onClose={() => setActiveChannel(null)}
channels={cachedChannels}
channels={visibleChannels}
onChannelChange={setActiveChannel}
channelsBySource={cachedChannelsBySource}
sources={sources}
channelsBySource={visibleChannelsBySource}
sources={visibleSources}
/>
)}
</div>
+3
View File
@@ -448,6 +448,9 @@ function PlayerContent() {
poster={videoData.vod_pic}
type={videoData.type_name}
year={videoData.vod_year}
sourceMap={Object.fromEntries(
(groupedSources.length > 0 ? groupedSources : [{ id: videoId, source }]).map((item) => [item.source, item.id])
)}
size={20}
isPremium={isPremium}
/>
+1 -7
View File
@@ -13,7 +13,6 @@ import { Lock } from 'lucide-react';
*/
function syncIPTVSources(rawValue: string) {
const iptvStore = useIPTVStore.getState();
const existingUrls = new Set(iptvStore.sources.map(s => s.url));
let entries: { name: string; url: string }[] = [];
@@ -34,12 +33,7 @@ function syncIPTVSources(rawValue: string) {
}
}
// Add new sources that don't already exist
for (const entry of entries) {
if (!existingUrls.has(entry.url)) {
iptvStore.addSource(entry.name || '直播源', entry.url);
}
}
iptvStore.syncBuiltinSources(entries);
}
/**
+4 -1
View File
@@ -18,6 +18,7 @@ interface FavoriteButtonProps {
type?: string;
year?: string;
remarks?: string;
sourceMap?: Record<string, string | number>;
className?: string;
size?: number;
showTooltip?: boolean;
@@ -33,6 +34,7 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
type,
year,
remarks,
sourceMap,
className = '',
size = 20,
showTooltip = true,
@@ -61,11 +63,12 @@ export const FavoriteButton = memo<FavoriteButtonProps>(({
type,
year,
remarks,
sourceMap,
});
setIsFav(newState);
setTimeout(() => setIsAnimating(false), 300);
}, [videoId, source, title, poster, sourceName, type, year, remarks, toggleFavorite]);
}, [videoId, source, title, poster, sourceName, type, year, remarks, sourceMap, toggleFavorite]);
return (
<button
+14
View File
@@ -5,6 +5,8 @@
import { Icons } from '@/components/ui/Icon';
import { formatDate } from '@/lib/utils/format-utils';
import { getSourceName } from '@/lib/utils/source-names';
import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache';
import type { FavoriteItem } from '@/lib/types';
interface FavoritesItemProps {
@@ -20,6 +22,18 @@ export function FavoritesItem({ item, onRemove, isPremium = false }: FavoritesIt
source: item.source,
title: item.title,
});
if (item.sourceMap && Object.keys(item.sourceMap).length > 1) {
const groupData = Object.entries(item.sourceMap).map(([sourceName, videoId]) => ({
id: videoId,
source: sourceName,
sourceName: getSourceName(sourceName),
pic: item.poster,
}));
const cacheKey = storeGroupedSources(groupData);
if (cacheKey) {
params.set('gs', cacheKey);
}
}
if (isPremium) {
params.set('premium', '1');
}
+2
View File
@@ -32,6 +32,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
id: videoId,
source: sourceName,
sourceName: getSourceName(sourceName),
pic: item.poster,
}));
const cacheKey = storeGroupedSources(groupData);
if (cacheKey) {
@@ -101,6 +102,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
title={item.title}
poster={item.poster}
remarks={episodeText}
sourceMap={item.sourceMap}
size={14}
className="!p-1.5 !bg-transparent !border-0 !shadow-none hover:!bg-[var(--glass-bg)]"
showTooltip={false}
+90 -83
View File
@@ -71,6 +71,15 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
const visibleChannels = filteredChannels.slice(0, visibleCount);
const hasMore = visibleCount < filteredChannels.length;
const orderedSources = useMemo(() => {
if (!sources || !selectedSourceId) return sources || [];
const selected = sources.find((source) => source.id === selectedSourceId);
return selected ? [selected, ...sources.filter((source) => source.id !== selectedSourceId)] : sources;
}, [sources, selectedSourceId]);
const orderedGroups = useMemo(() => {
if (!selectedGroup) return effectiveGroups;
return [selectedGroup, ...effectiveGroups.filter((group) => group !== selectedGroup)];
}, [effectiveGroups, selectedGroup]);
if (channels.length === 0) {
return (
@@ -84,92 +93,90 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
return (
<div className="space-y-4">
{/* Search + Group Filter */}
<div className="flex gap-3">
<div className="relative flex-1">
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
<input
type="text"
placeholder="搜索频道..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
/>
<div className="sticky top-4 z-10 -mx-2 px-2 py-2 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[color-mix(in_srgb,var(--bg-color)_88%,transparent)] backdrop-blur-md">
<div className="flex gap-3">
<div className="relative flex-1">
<Icons.Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-color-secondary)]" />
<input
type="text"
placeholder="搜索频道..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-sm text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)]/50 focus:outline-none focus:border-[var(--accent-color)]"
/>
</div>
</div>
<div className="mt-3 text-xs text-[var(--text-color-secondary)]">
{filteredChannels.length === effectiveChannels.length
? `${effectiveChannels.length} 个频道`
: `${filteredChannels.length} / ${effectiveChannels.length} 个频道`}
</div>
{hasMultipleSources && sources && (
<div className="mt-3 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>
{orderedSources.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>
)}
{effectiveGroups.length > 0 && (
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedGroup(null)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === 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'
}`}
>
({effectiveChannels.length})
</button>
{orderedGroups.map((group) => {
const count = effectiveChannels.filter((channel) => channel.group === group).length;
return (
<button
key={group}
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === group
? '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'
}`}
>
{group} ({count})
</button>
);
})}
</div>
)}
</div>
{/* Channel count */}
<div className="text-xs text-[var(--text-color-secondary)]">
{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 */}
{effectiveGroups.length > 0 && (
<div className="flex gap-1.5 flex-wrap">
<button
onClick={() => setSelectedGroup(null)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === 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'
}`}
>
({effectiveChannels.length})
</button>
{effectiveGroups.map((group) => {
const count = effectiveChannels.filter((c) => c.group === group).length;
return (
<button
key={group}
onClick={() => setSelectedGroup(group === selectedGroup ? null : group)}
className={`px-3 py-1 text-xs rounded-[var(--radius-2xl)] border transition-all cursor-pointer ${
selectedGroup === group
? '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'
}`}
>
{group} ({count})
</button>
);
})}
</div>
)}
{/* Channel Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{visibleChannels.map((channel, index) => (
+31 -5
View File
@@ -54,6 +54,22 @@ function formatTime(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`;
}
function getSeekRange(video: HTMLVideoElement): { start: number; end: number; duration: number } | null {
if (video.seekable.length > 0) {
const start = video.seekable.start(0);
const end = video.seekable.end(video.seekable.length - 1);
if (isFinite(start) && isFinite(end) && end > start) {
return { start, end, duration: end - start };
}
}
if (isFinite(video.duration) && video.duration > 0) {
return { start: 0, end: video.duration, duration: video.duration };
}
return null;
}
export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channelsBySource, sources }: IPTVPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsRef = useRef<Hls | null>(null);
@@ -82,8 +98,6 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
const [showAllRoutes, setShowAllRoutes] = useState(false);
// Multi-level sidebar state
const [activeSourceId, setActiveSourceId] = useState<string | null>(null);
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
@@ -434,12 +448,24 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
if (isLive) return;
const video = videoRef.current;
const bar = progressRef.current;
if (!video || !duration || !bar) return;
if (!video || !bar) return;
const seekRange = getSeekRange(video);
if (!seekRange) return;
const rect = bar.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
video.currentTime = seekRange.start + ratio * seekRange.duration;
};
const progressPercent = useMemo(() => {
const video = videoRef.current;
const seekRange = video ? getSeekRange(video) : null;
if (seekRange) {
return Math.max(0, Math.min(100, ((currentTime - seekRange.start) / seekRange.duration) * 100));
}
if (!duration) return 0;
return Math.max(0, Math.min(100, (currentTime / duration) * 100));
}, [currentTime, duration]);
const toggleFullscreen = async () => {
if (!containerRef.current) return;
if (document.fullscreenElement) {
@@ -789,7 +815,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
>
<div
className="h-full bg-[var(--accent-color)] rounded-full relative pointer-events-none"
style={{ width: `${(currentTime / duration) * 100}%` }}
style={{ width: `${progressPercent}%` }}
>
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
+36 -18
View File
@@ -7,6 +7,7 @@
import { useState } from 'react';
import { useIPTVStore, type IPTVSource } from '@/lib/store/iptv-store';
import { Icons } from '@/components/ui/Icon';
import { hasPermission } from '@/lib/store/auth-store';
const inputProps = {
spellCheck: false,
@@ -21,12 +22,14 @@ const inputProps = {
export function IPTVSourceManager() {
const { sources, addSource, removeSource, updateSource, refreshSources, isLoading } = useIPTVStore();
const canUseBuiltinSources = hasPermission('iptv_builtin_sources');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editUrl, setEditUrl] = useState('');
const visibleSources = sources.filter((source) => canUseBuiltinSources || source.kind !== 'builtin');
const handleAdd = () => {
if (!name.trim() || !url.trim()) return;
@@ -73,7 +76,7 @@ export function IPTVSourceManager() {
<div className="flex gap-2 flex-wrap">
<button
onClick={() => refreshSources()}
disabled={isLoading || sources.length === 0}
disabled={isLoading || visibleSources.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] hover:border-[var(--accent-color)]/30 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
<Icons.RefreshCw size={12} className={isLoading ? 'animate-spin' : ''} />
@@ -127,13 +130,13 @@ export function IPTVSourceManager() {
)}
{/* Source List */}
{sources.length === 0 ? (
{visibleSources.length === 0 ? (
<div className="text-center py-8 text-sm text-[var(--text-color-secondary)]">
M3U JSON
</div>
) : (
<div className="space-y-2">
{sources.map((source) => (
{visibleSources.map((source) => (
<div
key={source.id}
className="p-3 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)]"
@@ -175,24 +178,39 @@ export function IPTVSourceManager() {
/* Display Mode */
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-[var(--text-color)] truncate">{source.name}</p>
{source.kind === 'builtin' && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-[var(--accent-color)]/10 text-[var(--accent-color)]">
</span>
)}
</div>
<p className="text-xs text-[var(--text-color-secondary)] truncate">{source.url}</p>
</div>
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
<button
onClick={() => startEdit(source)}
className="p-1.5 text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors cursor-pointer"
title="编辑"
>
<Icons.Edit size={14} />
</button>
<button
onClick={() => removeSource(source.id)}
className="p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
title="删除"
>
<Icons.Trash size={14} />
</button>
{source.kind !== 'builtin' ? (
<>
<button
onClick={() => startEdit(source)}
className="p-1.5 text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors cursor-pointer"
title="编辑"
>
<Icons.Edit size={14} />
</button>
<button
onClick={() => removeSource(source.id)}
className="p-1.5 text-[var(--text-color-secondary)] hover:text-red-500 transition-colors cursor-pointer"
title="删除"
>
<Icons.Trash size={14} />
</button>
</>
) : (
<span className="text-[10px] text-[var(--text-color-secondary)] px-2">
</span>
)}
</div>
</div>
)}
+1
View File
@@ -31,6 +31,7 @@ export function SourceBadgeItem({
return (
<button
type="button"
ref={innerRef}
onClick={handleClick}
onFocus={onFocus}
+1
View File
@@ -161,6 +161,7 @@ export function SourceBadgeList({ sources, selectedSources, onToggleSource }: So
{hasOverflow && (
<button
type="button"
onClick={toggleExpanded}
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors self-start cursor-pointer"
+1
View File
@@ -29,6 +29,7 @@ export function TypeBadgeItem({
return (
<button
type="button"
ref={innerRef}
onClick={handleClick}
onFocus={onFocus}
+1
View File
@@ -116,6 +116,7 @@ export function TypeBadgeList({ badges, selectedTypes, onToggleType }: TypeBadge
</div>
{hasOverflow && (
<button
type="button"
onClick={toggleExpanded}
className="mt-2 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)]
flex items-center gap-1 transition-colors self-start cursor-pointer"
+1
View File
@@ -168,6 +168,7 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
type={representative.type_name}
year={representative.vod_year}
remarks={representative.vod_remarks}
sourceMap={Object.fromEntries(videos.map((video) => [video.source, video.vod_id]))}
size={16}
className="shadow-md"
isPremium={isPremium}
+4 -2
View File
@@ -27,11 +27,13 @@ const ALL_PERMISSIONS: { key: Permission; label: string }[] = [
{ key: 'player_settings', label: '播放器设置' },
{ key: 'danmaku_appearance', label: '弹幕外观' },
{ key: 'iptv_access', label: 'IPTV 访问' },
{ key: 'iptv_source_management', label: 'IPTV 自定义源管理' },
{ key: 'iptv_builtin_sources', label: 'IPTV 内置源' },
];
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
viewer: ['view_settings'],
};
+14
View File
@@ -5,6 +5,17 @@ import { ModalBackdrop } from '@/components/ui/ModalBackdrop';
import { ModalHeader } from '@/components/ui/ModalHeader';
import type { VideoSource } from '@/lib/types';
const inputProps = {
spellCheck: false,
autoCorrect: 'off' as const,
autoCapitalize: 'off' as const,
autoComplete: 'off' as const,
'data-form-type': 'other',
'data-lpignore': 'true',
lang: 'en',
translate: 'no' as const,
};
interface AddSourceModalProps {
isOpen: boolean;
onClose: () => void;
@@ -49,6 +60,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:新视频源"
{...inputProps}
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
/>
</div>
@@ -64,6 +76,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
onChange={(e) => setCustomId(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
placeholder="自动生成,可手动修改"
disabled={isEditing}
{...inputProps}
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] disabled:opacity-50"
/>
<p className="mt-1 text-xs text-[var(--text-color-secondary)]">
@@ -81,6 +94,7 @@ export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialVal
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/api.php/provide/vod"
{...inputProps}
className="w-full bg-[var(--glass-bg)] backdrop-blur-md border border-[var(--glass-border)] rounded-[var(--radius-2xl)] px-4 py-3 text-[var(--text-color)] placeholder:text-[var(--text-color-secondary)] focus:outline-none focus:border-[var(--accent-color)] focus:ring-4 focus:ring-[color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s]"
/>
</div>
+5 -1
View File
@@ -94,9 +94,13 @@ export function useVideoPlayer(
}
const data = await response.json();
const sourceUnavailable =
response.status === 404 ||
(response.status === 400 && typeof data?.error === 'string' && data.error.toLowerCase().includes('source')) ||
(typeof data?.error === 'string' && data.error.includes('视频源不可用'));
if (!response.ok) {
if (response.status === 404) {
if (sourceUnavailable) {
setVideoError(data.error || '该视频源不可用。请返回并尝试其他来源。');
setLoading(false);
onSourceUnavailableRef.current?.();
+17 -6
View File
@@ -13,11 +13,13 @@ export type Permission =
| 'player_settings'
| 'danmaku_appearance'
| 'view_settings'
| 'iptv_access';
| 'iptv_access'
| 'iptv_source_management'
| 'iptv_builtin_sources';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access'],
super_admin: ['source_management', 'account_management', 'danmaku_api', 'data_management', 'player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
admin: ['player_settings', 'danmaku_appearance', 'view_settings', 'iptv_access', 'iptv_source_management', 'iptv_builtin_sources'],
viewer: ['view_settings'],
};
@@ -77,9 +79,18 @@ export function isAdmin(): boolean {
export function hasPermission(permission: Permission): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
if (ROLE_PERMISSIONS[session.role]?.includes(permission)) return true;
if (session.customPermissions?.includes(permission)) return true;
return false;
const permissions = new Set<Permission>([
...(ROLE_PERMISSIONS[session.role] || []),
...(session.customPermissions || []),
]);
// IPTV access should include managing personal IPTV sources by default.
if (permission === 'iptv_source_management' && permissions.has('iptv_access')) {
return true;
}
return permissions.has(permission);
}
export function hasRole(minimumRole: Role): boolean {
+112 -12
View File
@@ -4,13 +4,14 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { parseM3U, groupChannelsByName, type M3UChannel } from '@/lib/utils/m3u-parser';
import { parseM3U, groupChannelsByName, extractPlaylistReferences, type M3UChannel } from '@/lib/utils/m3u-parser';
export interface IPTVSource {
id: string;
name: string;
url: string;
addedAt: number;
kind?: 'custom' | 'builtin';
}
interface IPTVState {
@@ -26,6 +27,7 @@ interface IPTVActions {
addSource: (name: string, url: string) => void;
removeSource: (id: string) => void;
updateSource: (id: string, updates: Partial<Pick<IPTVSource, 'name' | 'url'>>) => void;
syncBuiltinSources: (entries: Array<{ name: string; url: string }>) => void;
refreshSources: () => Promise<void>;
setLoading: (loading: boolean) => void;
}
@@ -33,6 +35,8 @@ interface IPTVActions {
interface IPTVStore extends IPTVState, IPTVActions {}
const MAX_CONCURRENT = 3;
const MAX_REFERENCE_DEPTH = 3;
const MAX_REFERENCES_PER_FILE = 25;
async function fetchWithConcurrencyLimit<T>(
tasks: (() => Promise<T>)[],
@@ -53,6 +57,84 @@ async function fetchWithConcurrencyLimit<T>(
return results;
}
function buildIPTVProxyUrl(url: string, ua?: string, referer?: string): string {
const params = new URLSearchParams({ url });
if (ua) params.set('ua', ua);
if (referer) params.set('referer', referer);
return `/api/iptv?${params.toString()}`;
}
async function loadPlaylistChannels(
rootSource: IPTVSource,
target: { name: string; url: string; httpUserAgent?: string; httpReferrer?: string },
visited: Set<string>,
depth: number = 0
): Promise<{ channels: M3UChannel[]; groups: string[] }> {
if (!target.url || visited.has(target.url) || depth > MAX_REFERENCE_DEPTH) {
return { channels: [], groups: [] };
}
visited.add(target.url);
try {
const res = await fetch(buildIPTVProxyUrl(target.url, target.httpUserAgent, target.httpReferrer));
if (!res.ok) {
return { channels: [], groups: [] };
}
const text = await res.text();
const playlist = parseM3U(text);
const directChannels = playlist.channels.map((channel) => ({
...channel,
group: channel.group || (depth > 0 ? target.name : channel.group),
sourceId: rootSource.id,
sourceName: rootSource.name,
httpUserAgent: channel.httpUserAgent || target.httpUserAgent,
httpReferrer: channel.httpReferrer || target.httpReferrer,
}));
const directGroups = new Set(playlist.groups);
if (depth > 0 && directChannels.some((channel) => channel.group === target.name)) {
directGroups.add(target.name);
}
const references = extractPlaylistReferences(text, target.url).slice(0, MAX_REFERENCES_PER_FILE);
if (references.length === 0) {
return { channels: directChannels, groups: Array.from(directGroups).sort() };
}
const nestedResults = await fetchWithConcurrencyLimit(
references.map((reference) => async () =>
loadPlaylistChannels(
rootSource,
{
name: reference.name,
url: reference.url,
httpUserAgent: reference.httpUserAgent,
httpReferrer: reference.httpReferrer,
},
visited,
depth + 1
)
),
MAX_CONCURRENT
);
const mergedChannels = [...directChannels, ...nestedResults.flatMap((result) => result.channels)];
const mergedGroups = new Set<string>([
...Array.from(directGroups),
...nestedResults.flatMap((result) => result.groups),
]);
return {
channels: mergedChannels,
groups: Array.from(mergedGroups).sort(),
};
} catch {
return { channels: [], groups: [] };
}
}
export const useIPTVStore = create<IPTVStore>()(
persist(
(set, get) => ({
@@ -66,7 +148,7 @@ export const useIPTVStore = create<IPTVStore>()(
addSource: (name, url) => {
const id = `iptv-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
set((state) => ({
sources: [...state.sources, { id, name, url, addedAt: Date.now() }],
sources: [...state.sources, { id, name, url, addedAt: Date.now(), kind: 'custom' }],
}));
},
@@ -84,6 +166,27 @@ export const useIPTVStore = create<IPTVStore>()(
}));
},
syncBuiltinSources: (entries) => {
set((state) => {
const customSources = state.sources.filter((source) => source.kind !== 'builtin');
const existingUrls = new Set(customSources.map((source) => source.url));
const builtinSources = entries
.filter((entry) => entry.url.trim())
.filter((entry) => !existingUrls.has(entry.url))
.map((entry, index) => ({
id: `iptv-builtin-${index}-${entry.url}`,
name: entry.name || `直播源 ${index + 1}`,
url: entry.url,
addedAt: Date.now(),
kind: 'builtin' as const,
}));
return {
sources: [...customSources, ...builtinSources],
};
});
},
refreshSources: async () => {
const { sources } = get();
if (sources.length === 0) {
@@ -101,16 +204,13 @@ export const useIPTVStore = create<IPTVStore>()(
const tasks = sources.map((source) => async () => {
try {
const res = await fetch('/api/iptv?' + new URLSearchParams({ url: source.url }));
if (!res.ok) return;
const text = await res.text();
const playlist = parseM3U(text);
const playlist = await loadPlaylistChannels(
source,
{ name: source.name, url: source.url },
new Set<string>()
);
// Tag channels with source info
const tagged = playlist.channels.map(ch => ({
...ch,
sourceId: source.id,
sourceName: source.name,
}));
const tagged = playlist.channels;
allChannels.push(...tagged);
playlist.groups.forEach((g) => allGroups.add(g));
// Track per-source
@@ -155,7 +255,7 @@ export const useIPTVStore = create<IPTVStore>()(
{
name: 'kvideo-iptv-store',
partialize: (state) => ({
sources: state.sources,
sources: state.sources.filter((source) => source.kind !== 'builtin'),
lastRefreshed: state.lastRefreshed,
// Don't persist cachedChannels/cachedGroups - they can be very large
// and will be re-fetched on page load
+1
View File
@@ -119,6 +119,7 @@ export interface FavoriteItem {
type?: string; // movie type/category
year?: string;
remarks?: string; // e.g., episode info
sourceMap?: Record<string, string | number>; // Maps source name to videoId for source switching
}
// API Response Structures
+61 -7
View File
@@ -22,6 +22,60 @@ export interface M3UPlaylist {
groups: string[];
}
export interface PlaylistReference {
kind: 'playlist' | 'config';
name: string;
url: string;
httpUserAgent?: string;
httpReferrer?: string;
}
function resolveReferenceUrl(baseUrl: string | undefined, target: string): string {
if (!baseUrl) return target;
try {
return new URL(target, baseUrl).toString();
} catch {
return target;
}
}
export function extractPlaylistReferences(content: string, baseUrl?: string): PlaylistReference[] {
try {
const data = JSON.parse(content);
if (!data || typeof data !== 'object') return [];
const references: PlaylistReference[] = [];
if (Array.isArray((data as any).lives)) {
for (const entry of (data as any).lives) {
if (!entry || typeof entry.url !== 'string') continue;
references.push({
kind: 'playlist',
name: entry.name || entry.title || '直播源',
url: resolveReferenceUrl(baseUrl, entry.url),
httpUserAgent: entry.ua || entry.userAgent || entry.http_user_agent || entry.httpUserAgent,
httpReferrer: entry.referer || entry.referrer || entry.http_referrer || entry.httpReferrer,
});
}
}
if (Array.isArray((data as any).urls)) {
for (const entry of (data as any).urls) {
if (!entry || typeof entry.url !== 'string') continue;
references.push({
kind: 'config',
name: entry.name || entry.title || '配置源',
url: resolveReferenceUrl(baseUrl, entry.url),
});
}
}
return references;
} catch {
return [];
}
}
/**
* Try to parse content as JSON channel list.
* Supports formats:
@@ -36,7 +90,7 @@ function tryParseJSON(content: string): M3UPlaylist | null {
if (Array.isArray(data)) {
channels = data;
} else if (data && typeof data === 'object') {
channels = data.channels || data.list || data.items || [];
channels = data.channels || data.list || data.items || data.data || [];
if (!Array.isArray(channels)) return null;
} else {
return null;
@@ -48,18 +102,18 @@ function tryParseJSON(content: string): M3UPlaylist | null {
const first = channels[0];
if (!first || typeof first !== 'object') return null;
// Must have at least a name and url
if (!first.name && !first.title && !first.channel_name) return null;
if (!first.url && !first.stream_url && !first.src) return null;
if (!first.name && !first.title && !first.channel_name && !first.channel) return null;
if (!first.url && !first.stream_url && !first.src && !first.link && !first.stream) return null;
const groupSet = new Set<string>();
const parsed: M3UChannel[] = [];
for (const ch of channels) {
const name = ch.name || ch.title || ch.channel_name || '';
const url = ch.url || ch.stream_url || ch.src || '';
const name = ch.name || ch.title || ch.channel_name || ch.channel || '';
const url = ch.url || ch.stream_url || ch.src || ch.link || ch.stream || '';
if (!name || !url) continue;
const group = ch.group || ch.group_title || ch.category || '';
const group = ch.group || ch.group_title || ch.groupName || ch.category || '';
if (group) groupSet.add(group);
parsed.push({
@@ -70,7 +124,7 @@ function tryParseJSON(content: string): M3UPlaylist | null {
tvgId: ch.tvg_id || ch.tvgId || undefined,
tvgName: ch.tvg_name || ch.tvgName || undefined,
httpUserAgent: ch.http_user_agent || ch.httpUserAgent || ch.user_agent || undefined,
httpReferrer: ch.http_referrer || ch.httpReferrer || ch.referer || undefined,
httpReferrer: ch.http_referrer || ch.httpReferrer || ch.referer || ch.referrer || undefined,
});
}
+5
View File
@@ -31,11 +31,16 @@ export function parseVideoTitle(title: string): { cleanTitle: string, quality?:
*/
const QUALITY_PATTERNS: { pattern: RegExp; label: string; color: string }[] = [
{ pattern: /4k|2160p|uhd/i, label: '4K', color: 'bg-amber-500' },
{ pattern: /2k|1440p|qhd/i, label: '2K', color: 'bg-emerald-500' },
{ pattern: /蓝光|藍光|bluray|blu-ray|remux/i, label: '蓝光', color: 'bg-blue-500' },
{ pattern: /\bhdr\b|hdr10\+?/i, label: 'HDR', color: 'bg-violet-500' },
{ pattern: /1080p|1080i|full\s*hd|fhd/i, label: '1080P', color: 'bg-green-500' },
{ pattern: /超清|超高清/i, label: '超清', color: 'bg-green-500' },
{ pattern: /540p/i, label: '540P', color: 'bg-cyan-500' },
{ pattern: /720p|hd720/i, label: '720P', color: 'bg-teal-500' },
{ pattern: /480p/i, label: '480P', color: 'bg-sky-500' },
{ pattern: /360p/i, label: '360P', color: 'bg-gray-500' },
{ pattern: /高清|流畅/i, label: '高清', color: 'bg-sky-500' },
{ pattern: /web-?dl|webrip/i, label: 'WEB-DL', color: 'bg-indigo-500' },
{ pattern: /hdtv/i, label: 'HDTV', color: 'bg-teal-500' },
{ pattern: /dvd|dvdrip/i, label: 'DVD', color: 'bg-purple-500' },