mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 01:03:42 +08:00
Fix issue #72 follow-ups and IPTV source handling
This commit is contained in:
+14
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function SourceBadgeItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={innerRef}
|
||||
onClick={handleClick}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -29,6 +29,7 @@ export function TypeBadgeItem({
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={innerRef}
|
||||
onClick={handleClick}
|
||||
onFocus={onFocus}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user