feat: Implement user-defined video sources and danmaku API management with new settings pages and a dedicated store.

This commit is contained in:
kuekhaoyang
2026-02-19 11:56:14 +08:00
parent e1ea1a0d9a
commit 9ea9f85507
26 changed files with 1097 additions and 151 deletions
+12 -2
View File
@@ -4,6 +4,7 @@ import { useSearchCache } from '@/lib/hooks/useSearchCache';
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
import { settingsStore, type SortOption } from '@/lib/store/settings-store';
import { userSourcesStore } from '@/lib/store/user-sources-store';
export function useHomePage() {
useSubscriptionSync();
@@ -48,11 +49,20 @@ export function useHomePage() {
const settings = settingsStore.getSettings();
const enabledSources = settings.sources.filter(s => s.enabled);
if (enabledSources.length === 0) {
// Merge user personal sources
const userSources = userSourcesStore.getSources().filter(s => s.enabled !== false);
const allSources = [...enabledSources];
for (const us of userSources) {
if (!allSources.find(s => s.id === us.id)) {
allSources.push(us);
}
}
if (allSources.length === 0) {
return false;
}
performSearch(searchQuery, enabledSources, settings.sortBy);
performSearch(searchQuery, allSources, settings.sortBy);
hasSearchedWithSourcesRef.current = true;
return true;
}, [performSearch]);
+4 -1
View File
@@ -25,6 +25,7 @@ export interface AuthSession {
profileId: string;
name: string;
role: Role;
customPermissions?: Permission[];
}
const SESSION_KEY = 'kvideo-session';
@@ -76,7 +77,9 @@ export function isAdmin(): boolean {
export function hasPermission(permission: Permission): boolean {
const session = getSession();
if (!session) return true; // No auth configured = full access
return ROLE_PERMISSIONS[session.role]?.includes(permission) ?? false;
if (ROLE_PERMISSIONS[session.role]?.includes(permission)) return true;
if (session.customPermissions?.includes(permission)) return true;
return false;
}
export function hasRole(minimumRole: Role): boolean {
+116
View File
@@ -0,0 +1,116 @@
/**
* User Sources Store - Per-user video sources and danmaku APIs
* Uses localStorage keyed by profileId for isolation between users
*/
import { getProfileId } from './auth-store';
import type { VideoSource } from '@/lib/types';
export interface DanmakuApiEntry {
id: string;
name: string;
url: string;
}
interface UserSourcesState {
sources: VideoSource[];
danmakuApis: DanmakuApiEntry[];
activeDanmakuApiId: string | null;
}
function getStorageKey(): string {
const profileId = getProfileId();
return `kvideo-user-sources-${profileId}`;
}
function getState(): UserSourcesState {
if (typeof window === 'undefined') {
return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
}
try {
const raw = localStorage.getItem(getStorageKey());
if (!raw) return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
const parsed = JSON.parse(raw);
return {
sources: Array.isArray(parsed.sources) ? parsed.sources : [],
danmakuApis: Array.isArray(parsed.danmakuApis) ? parsed.danmakuApis : [],
activeDanmakuApiId: parsed.activeDanmakuApiId ?? null,
};
} catch {
return { sources: [], danmakuApis: [], activeDanmakuApiId: null };
}
}
function saveState(state: UserSourcesState): void {
if (typeof window === 'undefined') return;
localStorage.setItem(getStorageKey(), JSON.stringify(state));
userSourcesStore.notifyListeners();
}
export const userSourcesStore = {
listeners: new Set<() => void>(),
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
},
notifyListeners(): void {
this.listeners.forEach(l => l());
},
getState,
getSources(): VideoSource[] {
return getState().sources;
},
getDanmakuApis(): DanmakuApiEntry[] {
return getState().danmakuApis;
},
getActiveDanmakuApi(): DanmakuApiEntry | null {
const state = getState();
if (!state.activeDanmakuApiId) return null;
return state.danmakuApis.find(a => a.id === state.activeDanmakuApiId) ?? null;
},
addSource(source: VideoSource): void {
const state = getState();
if (state.sources.find(s => s.id === source.id)) return;
saveState({ ...state, sources: [...state.sources, { ...source, enabled: true }] });
},
removeSource(id: string): void {
const state = getState();
saveState({ ...state, sources: state.sources.filter(s => s.id !== id) });
},
toggleSource(id: string): void {
const state = getState();
saveState({
...state,
sources: state.sources.map(s =>
s.id === id ? { ...s, enabled: !s.enabled } : s
),
});
},
addDanmakuApi(entry: DanmakuApiEntry): void {
const state = getState();
if (state.danmakuApis.find(a => a.id === entry.id)) return;
saveState({ ...state, danmakuApis: [...state.danmakuApis, entry] });
},
removeDanmakuApi(id: string): void {
const state = getState();
const newApis = state.danmakuApis.filter(a => a.id !== id);
const newActiveId = state.activeDanmakuApiId === id ? null : state.activeDanmakuApiId;
saveState({ ...state, danmakuApis: newApis, activeDanmakuApiId: newActiveId });
},
setActiveDanmakuApi(id: string | null): void {
const state = getState();
saveState({ ...state, activeDanmakuApiId: id });
},
};
+1
View File
@@ -51,6 +51,7 @@ export interface SourceBadge {
id: string;
name: string;
count: number;
typeName?: string;
}
export interface TypeBadge {
+6
View File
@@ -13,6 +13,8 @@ export interface M3UChannel {
routes?: string[];
sourceId?: string;
sourceName?: string;
httpUserAgent?: string;
httpReferrer?: string;
}
export interface M3UPlaylist {
@@ -40,6 +42,8 @@ export function parseM3U(content: string): M3UPlaylist {
const tvgLogoMatch = line.match(/tvg-logo="([^"]*)"/i);
const groupTitleMatch = line.match(/group-title="([^"]*)"/i);
const tvgIdMatch = line.match(/tvg-id="([^"]*)"/i);
const httpUserAgentMatch = line.match(/http-user-agent="([^"]*)"/i);
const httpReferrerMatch = line.match(/http-referrer="([^"]*)"/i);
if (tvgNameMatch) channel.tvgName = tvgNameMatch[1];
if (tvgLogoMatch) channel.logo = tvgLogoMatch[1];
@@ -48,6 +52,8 @@ export function parseM3U(content: string): M3UPlaylist {
if (channel.group) groupSet.add(channel.group);
}
if (tvgIdMatch) channel.tvgId = tvgIdMatch[1];
if (httpUserAgentMatch) channel.httpUserAgent = httpUserAgentMatch[1];
if (httpReferrerMatch) channel.httpReferrer = httpReferrerMatch[1];
// Extract channel name (after last comma)
const commaIndex = line.lastIndexOf(',');