From 91a89e8bf23d0f23b2e789ba898c99ce49ed937a Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 26 Mar 2026 14:16:58 +0800 Subject: [PATCH] Fix issue #72 follow-ups and IPTV source handling --- app/api/iptv/route.ts | 15 +- app/iptv/page.tsx | 45 ++++-- app/player/page.tsx | 3 + components/PasswordGate.tsx | 8 +- components/favorites/FavoriteButton.tsx | 5 +- components/favorites/FavoritesItem.tsx | 14 ++ components/history/HistoryItem.tsx | 2 + components/iptv/IPTVChannelGrid.tsx | 173 ++++++++++++------------ components/iptv/IPTVPlayer.tsx | 36 ++++- components/iptv/IPTVSourceManager.tsx | 54 +++++--- components/search/SourceBadgeItem.tsx | 1 + components/search/SourceBadgeList.tsx | 1 + components/search/TypeBadgeItem.tsx | 1 + components/search/TypeBadgeList.tsx | 1 + components/search/VideoGroupCard.tsx | 1 + components/settings/AccountSettings.tsx | 6 +- components/settings/AddSourceModal.tsx | 14 ++ lib/hooks/useVideoPlayer.ts | 6 +- lib/store/auth-store.ts | 23 +++- lib/store/iptv-store.ts | 124 +++++++++++++++-- lib/types/index.ts | 1 + lib/utils/m3u-parser.ts | 68 +++++++++- lib/utils/video.ts | 5 + 23 files changed, 453 insertions(+), 154 deletions(-) diff --git a/app/api/iptv/route.ts b/app/api/iptv/route.ts index 7f2516a..31dc6eb 100644 --- a/app/api/iptv/route.ts +++ b/app/api/iptv/route.ts @@ -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, }, }); diff --git a/app/iptv/page.tsx b/app/iptv/page.tsx index ba9b10f..2803ed7 100644 --- a/app/iptv/page.tsx +++ b/app/iptv/page.tsx @@ -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(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() { 直播

- {cachedChannels.length > 0 ? `${cachedChannels.length} 个频道` : 'IPTV 直播频道'} + {visibleChannels.length > 0 ? `${visibleChannels.length} 个频道` : 'IPTV 直播频道'}

@@ -101,12 +124,12 @@ export default function IPTVPage() { {!isLoading && (
)} @@ -117,10 +140,10 @@ export default function IPTVPage() { setActiveChannel(null)} - channels={cachedChannels} + channels={visibleChannels} onChannelChange={setActiveChannel} - channelsBySource={cachedChannelsBySource} - sources={sources} + channelsBySource={visibleChannelsBySource} + sources={visibleSources} /> )} diff --git a/app/player/page.tsx b/app/player/page.tsx index 7772c66..75fc010 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -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} /> diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx index 7152efe..ae62ad9 100644 --- a/components/PasswordGate.tsx +++ b/components/PasswordGate.tsx @@ -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); } /** diff --git a/components/favorites/FavoriteButton.tsx b/components/favorites/FavoriteButton.tsx index dedb1a8..5cd665e 100644 --- a/components/favorites/FavoriteButton.tsx +++ b/components/favorites/FavoriteButton.tsx @@ -18,6 +18,7 @@ interface FavoriteButtonProps { type?: string; year?: string; remarks?: string; + sourceMap?: Record; className?: string; size?: number; showTooltip?: boolean; @@ -33,6 +34,7 @@ export const FavoriteButton = memo(({ type, year, remarks, + sourceMap, className = '', size = 20, showTooltip = true, @@ -61,11 +63,12 @@ export const FavoriteButton = memo(({ 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 ( + {orderedSources.map((source) => { + const sourceData = channelsBySource![source.id]; + if (!sourceData) return null; + return ( + + ); + })} + + )} + + {effectiveGroups.length > 0 && ( +
+ + {orderedGroups.map((group) => { + const count = effectiveChannels.filter((channel) => channel.group === group).length; + return ( + + ); + })} +
+ )} - {/* Channel count */} -
- {filteredChannels.length === effectiveChannels.length - ? `共 ${effectiveChannels.length} 个频道` - : `${filteredChannels.length} / ${effectiveChannels.length} 个频道`} -
- - {/* Source Tabs (only when multiple sources) */} - {hasMultipleSources && sources && ( -
- - {sources.map((source) => { - const sourceData = channelsBySource![source.id]; - if (!sourceData) return null; - return ( - - ); - })} -
- )} - - {/* Group Tabs */} - {effectiveGroups.length > 0 && ( -
- - {effectiveGroups.map((group) => { - const count = effectiveChannels.filter((c) => c.group === group).length; - return ( - - ); - })} -
- )} - {/* Channel Grid */}
{visibleChannels.map((channel, index) => ( diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index d785f43..c4f0c98 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -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(null); const hlsRef = useRef(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(null); - const [activeGroup, setActiveGroup] = useState(null); const [expandedSources, setExpandedSources] = useState>(new Set()); const [expandedGroups, setExpandedGroups] = useState>(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 >
diff --git a/components/iptv/IPTVSourceManager.tsx b/components/iptv/IPTVSourceManager.tsx index c72f3ae..127c103 100644 --- a/components/iptv/IPTVSourceManager.tsx +++ b/components/iptv/IPTVSourceManager.tsx @@ -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(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() {
- + {source.kind !== 'builtin' ? ( + <> + + + + ) : ( + + 环境变量提供 + + )}
)} diff --git a/components/search/SourceBadgeItem.tsx b/components/search/SourceBadgeItem.tsx index 9c9339d..14039ec 100644 --- a/components/search/SourceBadgeItem.tsx +++ b/components/search/SourceBadgeItem.tsx @@ -31,6 +31,7 @@ export function SourceBadgeItem({ return (
@@ -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" />

@@ -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]" /> diff --git a/lib/hooks/useVideoPlayer.ts b/lib/hooks/useVideoPlayer.ts index 22d0e0d..66746d7 100644 --- a/lib/hooks/useVideoPlayer.ts +++ b/lib/hooks/useVideoPlayer.ts @@ -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?.(); diff --git a/lib/store/auth-store.ts b/lib/store/auth-store.ts index e925e5d..edb7070 100644 --- a/lib/store/auth-store.ts +++ b/lib/store/auth-store.ts @@ -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 = { - 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([ + ...(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 { diff --git a/lib/store/iptv-store.ts b/lib/store/iptv-store.ts index af46d45..9dbd7dc 100644 --- a/lib/store/iptv-store.ts +++ b/lib/store/iptv-store.ts @@ -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>) => void; + syncBuiltinSources: (entries: Array<{ name: string; url: string }>) => void; refreshSources: () => Promise; 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( tasks: (() => Promise)[], @@ -53,6 +57,84 @@ async function fetchWithConcurrencyLimit( 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, + 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([ + ...Array.from(directGroups), + ...nestedResults.flatMap((result) => result.groups), + ]); + + return { + channels: mergedChannels, + groups: Array.from(mergedGroups).sort(), + }; + } catch { + return { channels: [], groups: [] }; + } +} + export const useIPTVStore = create()( persist( (set, get) => ({ @@ -66,7 +148,7 @@ export const useIPTVStore = create()( 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()( })); }, + 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()( 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() + ); // 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()( { 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 diff --git a/lib/types/index.ts b/lib/types/index.ts index 7aa2af3..d7f5f31 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -119,6 +119,7 @@ export interface FavoriteItem { type?: string; // movie type/category year?: string; remarks?: string; // e.g., episode info + sourceMap?: Record; // Maps source name to videoId for source switching } // API Response Structures diff --git a/lib/utils/m3u-parser.ts b/lib/utils/m3u-parser.ts index 812a1a3..2827a30 100644 --- a/lib/utils/m3u-parser.ts +++ b/lib/utils/m3u-parser.ts @@ -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(); 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, }); } diff --git a/lib/utils/video.ts b/lib/utils/video.ts index 79e97bf..663dd81 100644 --- a/lib/utils/video.ts +++ b/lib/utils/video.ts @@ -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' },