mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-21 03:33:42 +08:00
feat: Implement IPTV functionality with channel management and player, and add admin account configuration generation.
This commit is contained in:
+83
-18
@@ -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;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user