mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
266 lines
8.3 KiB
TypeScript
266 lines
8.3 KiB
TypeScript
/**
|
|
* IPTV Store - Manages IPTV/M3U playlist sources and cached channels
|
|
*/
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
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 {
|
|
sources: IPTVSource[];
|
|
cachedChannels: M3UChannel[];
|
|
cachedGroups: string[];
|
|
cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }>;
|
|
lastRefreshed: number;
|
|
isLoading: boolean;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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>)[],
|
|
limit: number
|
|
): Promise<T[]> {
|
|
const results: T[] = [];
|
|
let index = 0;
|
|
|
|
async function runNext(): Promise<void> {
|
|
while (index < tasks.length) {
|
|
const currentIndex = index++;
|
|
results[currentIndex] = await tasks[currentIndex]();
|
|
}
|
|
}
|
|
|
|
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => runNext());
|
|
await Promise.all(workers);
|
|
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, target.url);
|
|
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) => ({
|
|
sources: [],
|
|
cachedChannels: [],
|
|
cachedGroups: [],
|
|
cachedChannelsBySource: {},
|
|
lastRefreshed: 0,
|
|
isLoading: false,
|
|
|
|
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(), kind: 'custom' }],
|
|
}));
|
|
},
|
|
|
|
removeSource: (id) => {
|
|
set((state) => ({
|
|
sources: state.sources.filter((s) => s.id !== id),
|
|
}));
|
|
},
|
|
|
|
updateSource: (id, updates) => {
|
|
set((state) => ({
|
|
sources: state.sources.map((s) =>
|
|
s.id === id ? { ...s, ...updates } : s
|
|
),
|
|
}));
|
|
},
|
|
|
|
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) {
|
|
set({ cachedChannels: [], cachedGroups: [], cachedChannelsBySource: {}, lastRefreshed: Date.now() });
|
|
return;
|
|
}
|
|
|
|
set({ isLoading: true });
|
|
|
|
try {
|
|
const allChannels: M3UChannel[] = [];
|
|
const allGroups = new Set<string>();
|
|
const channelsBySourceRaw: Record<string, M3UChannel[]> = {};
|
|
const groupsBySource: Record<string, Set<string>> = {};
|
|
|
|
const tasks = sources.map((source) => async () => {
|
|
try {
|
|
const playlist = await loadPlaylistChannels(
|
|
source,
|
|
{ name: source.name, url: source.url },
|
|
new Set<string>()
|
|
);
|
|
// Tag channels with source info
|
|
const tagged = playlist.channels;
|
|
allChannels.push(...tagged);
|
|
playlist.groups.forEach((g) => allGroups.add(g));
|
|
// Track per-source
|
|
channelsBySourceRaw[source.id] = tagged;
|
|
groupsBySource[source.id] = new Set(playlist.groups);
|
|
} catch (e) {
|
|
console.error(`Failed to fetch IPTV source: ${source.name}`, e);
|
|
}
|
|
});
|
|
|
|
await fetchWithConcurrencyLimit(tasks, MAX_CONCURRENT);
|
|
|
|
// Group channels with the same name into multi-route entries
|
|
const grouped = groupChannelsByName(allChannels);
|
|
|
|
// Build per-source grouped data
|
|
const cachedChannelsBySource: Record<string, { channels: M3UChannel[]; groups: string[] }> = {};
|
|
for (const source of sources) {
|
|
const raw = channelsBySourceRaw[source.id];
|
|
if (raw) {
|
|
cachedChannelsBySource[source.id] = {
|
|
channels: groupChannelsByName(raw),
|
|
groups: Array.from(groupsBySource[source.id] || []).sort(),
|
|
};
|
|
}
|
|
}
|
|
|
|
set({
|
|
cachedChannels: grouped,
|
|
cachedGroups: Array.from(allGroups).sort(),
|
|
cachedChannelsBySource,
|
|
lastRefreshed: Date.now(),
|
|
isLoading: false,
|
|
});
|
|
} catch {
|
|
set({ isLoading: false });
|
|
}
|
|
},
|
|
|
|
setLoading: (loading) => set({ isLoading: loading }),
|
|
}),
|
|
{
|
|
name: 'kvideo-iptv-store',
|
|
partialize: (state) => ({
|
|
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
|
|
}),
|
|
}
|
|
)
|
|
);
|