feat: Implement IPTV functionality with channel management and player, and add admin account configuration generation.

This commit is contained in:
kuekhaoyang
2026-02-17 16:37:53 +08:00
parent 91280f250f
commit 5d139114b5
30 changed files with 1362 additions and 50 deletions
+83 -18
View File
@@ -29,7 +29,7 @@ interface HistoryActions {
metadata?: { vod_actor?: string; type_name?: string; vod_area?: string }
) => void;
removeFromHistory: (videoId: string | number, source: string) => void;
removeFromHistory: (showIdentifier: string) => void;
clearHistory: () => void;
importHistory: (history: VideoHistoryItem[]) => void;
}
@@ -37,14 +37,55 @@ interface HistoryActions {
interface HistoryStore extends HistoryState, HistoryActions { }
/**
* Generate unique identifier for deduplication
* Generate unique identifier for deduplication (source-agnostic)
*/
function generateShowIdentifier(
title: string,
source: string,
videoId: string | number
): string {
return `${source}:${videoId}:${title.toLowerCase().trim()}`;
function generateShowIdentifier(title: string): string {
return `title:${title.toLowerCase().trim()}`;
}
/**
* Migrate v1 history entries to v2 (merge entries with same title)
*/
function migrateHistory(history: VideoHistoryItem[]): VideoHistoryItem[] {
const merged = new Map<string, VideoHistoryItem>();
for (const item of history) {
const newId = generateShowIdentifier(item.title);
const existing = merged.get(newId);
if (existing) {
// Keep the more recent entry, merge sourceMap
const isNewer = item.timestamp > existing.timestamp;
const mergedSourceMap = {
...(existing.sourceMap || { [existing.source]: existing.videoId }),
...(item.sourceMap || { [item.source]: item.videoId }),
};
merged.set(newId, {
...(isNewer ? item : existing),
showIdentifier: newId,
sourceMap: mergedSourceMap,
// Keep newer playback state
playbackPosition: isNewer ? item.playbackPosition : existing.playbackPosition,
duration: isNewer ? item.duration : existing.duration,
episodeIndex: isNewer ? item.episodeIndex : existing.episodeIndex,
url: isNewer ? item.url : existing.url,
source: isNewer ? item.source : existing.source,
videoId: isNewer ? item.videoId : existing.videoId,
timestamp: Math.max(item.timestamp, existing.timestamp),
episodes: (isNewer ? item.episodes : existing.episodes) || [],
poster: isNewer ? (item.poster || existing.poster) : (existing.poster || item.poster),
});
} else {
merged.set(newId, {
...item,
showIdentifier: newId,
sourceMap: item.sourceMap || { [item.source]: item.videoId },
});
}
}
return Array.from(merged.values()).sort((a, b) => b.timestamp - a.timestamp);
}
const createHistoryStore = (name: string) =>
@@ -65,11 +106,11 @@ const createHistoryStore = (name: string) =>
episodes = [],
metadata
) => {
const showIdentifier = generateShowIdentifier(title, source, videoId);
const showIdentifier = generateShowIdentifier(title);
const timestamp = Date.now();
set((state) => {
// Check if item already exists
// Check if item already exists (by normalized title)
const existingIndex = state.viewingHistory.findIndex(
(item) => item.showIdentifier === showIdentifier
);
@@ -77,18 +118,29 @@ const createHistoryStore = (name: string) =>
let newHistory: VideoHistoryItem[];
if (existingIndex !== -1) {
const existing = state.viewingHistory[existingIndex];
// Merge sourceMap
const mergedSourceMap = {
...(existing.sourceMap || { [existing.source]: existing.videoId }),
[source]: videoId,
};
// Update existing item and move to top
const updatedItem: VideoHistoryItem = {
...state.viewingHistory[existingIndex],
...existing,
videoId,
source,
url,
episodeIndex,
playbackPosition,
duration,
timestamp,
episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes,
vod_actor: metadata?.vod_actor ?? state.viewingHistory[existingIndex].vod_actor,
type_name: metadata?.type_name ?? state.viewingHistory[existingIndex].type_name,
vod_area: metadata?.vod_area ?? state.viewingHistory[existingIndex].vod_area,
sourceMap: mergedSourceMap,
episodes: episodes.length > 0 ? episodes : existing.episodes,
poster: poster || existing.poster,
vod_actor: metadata?.vod_actor ?? existing.vod_actor,
type_name: metadata?.type_name ?? existing.type_name,
vod_area: metadata?.vod_area ?? existing.vod_area,
};
newHistory = [
@@ -109,6 +161,7 @@ const createHistoryStore = (name: string) =>
poster,
episodes,
showIdentifier,
sourceMap: { [source]: videoId },
vod_actor: metadata?.vod_actor,
type_name: metadata?.type_name,
vod_area: metadata?.vod_area,
@@ -126,10 +179,10 @@ const createHistoryStore = (name: string) =>
});
},
removeFromHistory: (videoId, source) => {
removeFromHistory: (showIdentifier) => {
const state = get();
const itemToRemove = state.viewingHistory.find(
(item) => item.videoId === videoId && item.source === source
(item) => item.showIdentifier === showIdentifier
);
if (itemToRemove) {
@@ -139,7 +192,7 @@ const createHistoryStore = (name: string) =>
set((state) => ({
viewingHistory: state.viewingHistory.filter(
(item) => !(item.videoId === videoId && item.source === source)
(item) => item.showIdentifier !== showIdentifier
),
}));
},
@@ -156,6 +209,18 @@ const createHistoryStore = (name: string) =>
}),
{
name,
version: 2,
migrate: (persistedState: any, version: number) => {
if (version < 2) {
// Migrate from v1: merge entries with same normalized title
const oldHistory = persistedState?.viewingHistory || [];
return {
...persistedState,
viewingHistory: migrateHistory(oldHistory),
};
}
return persistedState as HistoryStore;
},
}
)
);
+106
View File
@@ -0,0 +1,106 @@
/**
* IPTV Store - Manages IPTV/M3U playlist sources and cached channels
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { parseM3U, type M3UChannel } from '@/lib/utils/m3u-parser';
export interface IPTVSource {
id: string;
name: string;
url: string;
addedAt: number;
}
interface IPTVState {
sources: IPTVSource[];
cachedChannels: M3UChannel[];
cachedGroups: string[];
lastRefreshed: number;
isLoading: boolean;
}
interface IPTVActions {
addSource: (name: string, url: string) => void;
removeSource: (id: string) => void;
refreshSources: () => Promise<void>;
setLoading: (loading: boolean) => void;
}
interface IPTVStore extends IPTVState, IPTVActions {}
export const useIPTVStore = create<IPTVStore>()(
persist(
(set, get) => ({
sources: [],
cachedChannels: [],
cachedGroups: [],
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() }],
}));
},
removeSource: (id) => {
set((state) => ({
sources: state.sources.filter((s) => s.id !== id),
}));
},
refreshSources: async () => {
const { sources } = get();
if (sources.length === 0) {
set({ cachedChannels: [], cachedGroups: [], lastRefreshed: Date.now() });
return;
}
set({ isLoading: true });
try {
const allChannels: M3UChannel[] = [];
const allGroups = new Set<string>();
await Promise.all(
sources.map(async (source) => {
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);
allChannels.push(...playlist.channels);
playlist.groups.forEach((g) => allGroups.add(g));
} catch (e) {
console.error(`Failed to fetch IPTV source: ${source.name}`, e);
}
})
);
set({
cachedChannels: allChannels,
cachedGroups: Array.from(allGroups).sort(),
lastRefreshed: Date.now(),
isLoading: false,
});
} catch {
set({ isLoading: false });
}
},
setLoading: (loading) => set({ isLoading: loading }),
}),
{
name: 'kvideo-iptv-store',
partialize: (state) => ({
sources: state.sources,
cachedChannels: state.cachedChannels,
cachedGroups: state.cachedGroups,
lastRefreshed: state.lastRefreshed,
}),
}
)
);
+4
View File
@@ -28,6 +28,7 @@ export interface ModeSettings {
danmakuApiUrl: string;
danmakuOpacity: number;
danmakuFontSize: number;
danmakuDisplayArea: number;
}
function getDefaultModeSettings(): ModeSettings {
@@ -51,6 +52,7 @@ function getDefaultModeSettings(): ModeSettings {
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
danmakuOpacity: 0.7,
danmakuFontSize: 20,
danmakuDisplayArea: 0.5,
};
}
@@ -87,6 +89,7 @@ export const premiumModeSettingsStore = {
danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''),
danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20,
danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5,
};
} catch {
return getDefaultModeSettings();
@@ -146,6 +149,7 @@ export function getModeSettings(isPremium: boolean): ModeSettings {
danmakuApiUrl: s.danmakuApiUrl,
danmakuOpacity: s.danmakuOpacity,
danmakuFontSize: s.danmakuFontSize,
danmakuDisplayArea: s.danmakuDisplayArea,
};
}
+3
View File
@@ -51,6 +51,7 @@ export interface AppSettings {
danmakuApiUrl: string; // Self-hosted danmaku API endpoint
danmakuOpacity: number; // 0.1 - 1.0
danmakuFontSize: number; // px
danmakuDisplayArea: number; // 0.25 | 0.5 | 0.75 | 1.0
}
import { exportSettings, importSettings, SEARCH_HISTORY_KEY, WATCH_HISTORY_KEY } from './settings-helpers';
@@ -128,6 +129,7 @@ function getDefaultAppSettings(): AppSettings {
danmakuApiUrl: process.env.NEXT_PUBLIC_DANMAKU_API_URL || '',
danmakuOpacity: 0.7,
danmakuFontSize: 20,
danmakuDisplayArea: 0.5,
};
}
@@ -209,6 +211,7 @@ export const settingsStore = {
danmakuApiUrl: typeof parsed.danmakuApiUrl === 'string' ? (parsed.danmakuApiUrl || process.env.NEXT_PUBLIC_DANMAKU_API_URL || '') : (process.env.NEXT_PUBLIC_DANMAKU_API_URL || ''),
danmakuOpacity: typeof parsed.danmakuOpacity === 'number' ? parsed.danmakuOpacity : 0.7,
danmakuFontSize: typeof parsed.danmakuFontSize === 'number' ? parsed.danmakuFontSize : 20,
danmakuDisplayArea: typeof parsed.danmakuDisplayArea === 'number' ? parsed.danmakuDisplayArea : 0.5,
};
} catch {
// Even if localStorage fails, we should return defaults + ENV subscriptions
+1
View File
@@ -101,6 +101,7 @@ export interface VideoHistoryItem {
poster?: string;
episodes: Episode[];
showIdentifier: string; // Unique identifier for deduplication
sourceMap?: Record<string, string | number>; // Maps source name to videoId for that source
vod_actor?: string;
type_name?: string;
vod_area?: string;
+78
View File
@@ -0,0 +1,78 @@
/**
* M3U Playlist Parser
* Parses M3U/M3U8 IPTV playlist format
*/
export interface M3UChannel {
name: string;
url: string;
logo?: string;
group?: string;
tvgId?: string;
tvgName?: string;
}
export interface M3UPlaylist {
channels: M3UChannel[];
groups: string[];
}
/**
* Parse M3U playlist content into structured data
*/
export function parseM3U(content: string): M3UPlaylist {
const lines = content.split('\n').map(l => l.trim()).filter(l => l.length > 0);
const channels: M3UChannel[] = [];
const groupSet = new Set<string>();
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('#EXTINF:')) {
// Parse EXTINF line
const channel: M3UChannel = { name: '', url: '' };
// Extract attributes from EXTINF
const tvgNameMatch = line.match(/tvg-name="([^"]*)"/i);
const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/i);
const groupTitleMatch = line.match(/group-title="([^"]*)"/i);
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/i);
if (tvgNameMatch) channel.tvgName = tvgNameMatch[1];
if (tvgLogoMatch) channel.logo = tvgLogoMatch[1];
if (groupTitleMatch) {
channel.group = groupTitleMatch[1];
if (channel.group) groupSet.add(channel.group);
}
if (tvgIdMatch) channel.tvgId = tvgIdMatch[1];
// Extract channel name (after last comma)
const commaIndex = line.lastIndexOf(',');
if (commaIndex !== -1) {
channel.name = line.substring(commaIndex + 1).trim();
}
// Next non-comment line should be the URL
for (let j = i + 1; j < lines.length; j++) {
if (!lines[j].startsWith('#')) {
channel.url = lines[j];
i = j; // Skip to after URL
break;
}
}
if (channel.name && channel.url) {
// Use tvgName as fallback for name
if (!channel.name && channel.tvgName) {
channel.name = channel.tvgName;
}
channels.push(channel);
}
}
}
return {
channels,
groups: Array.from(groupSet).sort(),
};
}