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
+6 -1
View File
@@ -16,6 +16,7 @@ const effectiveAdminPassword = ADMIN_PASSWORD || ACCESS_PASSWORD;
interface AccountInfo {
name: string;
role: 'super_admin' | 'admin' | 'viewer';
customPermissions?: string[];
}
function getAccountList(): AccountInfo[] {
@@ -37,8 +38,12 @@ function getAccountList(): AccountInfo[] {
const name = parts[1].trim();
const parsedRole = parts[2]?.trim();
const role = parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer';
const perms = parts[3]?.trim();
const customPermissions = perms
? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
: undefined;
if (name) {
accounts.push({ name, role });
accounts.push({ name, role, ...(customPermissions && customPermissions.length > 0 ? { customPermissions } : {}) });
}
}
});
+7 -1
View File
@@ -20,6 +20,7 @@ interface AccountEntry {
password: string;
name: string;
role: 'super_admin' | 'admin' | 'viewer';
customPermissions: string[];
}
function parseAccounts(): AccountEntry[] {
@@ -31,12 +32,16 @@ function parseAccounts(): AccountEntry[] {
.map(entry => {
const parts = entry.split(':');
if (parts.length < 2) return null;
const [password, name, role] = parts;
const [password, name, role, perms] = parts;
const parsedRole = role?.trim();
const customPermissions = perms
? perms.split('|').map(p => p.trim()).filter(p => p.length > 0)
: [];
return {
password: password.trim(),
name: name.trim(),
role: (parsedRole === 'super_admin' ? 'super_admin' : parsedRole === 'admin' ? 'admin' : 'viewer') as 'super_admin' | 'admin' | 'viewer',
customPermissions,
};
})
.filter((a): a is AccountEntry => a !== null && a.password.length > 0 && a.name.length > 0);
@@ -96,6 +101,7 @@ export async function POST(request: NextRequest) {
role: account.role,
profileId,
persistSession: PERSIST_SESSION,
customPermissions: account.customPermissions.length > 0 ? account.customPermissions : undefined,
});
}
}
+49 -5
View File
@@ -46,6 +46,8 @@ function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): strin
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 });
@@ -54,9 +56,9 @@ export async function GET(request: NextRequest) {
try {
const parsedUrl = new URL(url);
const fetchHeaders: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (compatible; KVideo/1.0)',
'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)',
'Accept': '*/*',
'Referer': `${parsedUrl.protocol}//${parsedUrl.host}/`,
'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`,
'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
};
@@ -66,7 +68,15 @@ export async function GET(request: NextRequest) {
fetchHeaders['Range'] = rangeHeader;
}
const response = await fetch(url, { headers: fetchHeaders });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
const response = await fetch(url, {
headers: fetchHeaders,
redirect: 'follow',
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return NextResponse.json(
@@ -76,10 +86,44 @@ export async function GET(request: NextRequest) {
}
const contentType = response.headers.get('content-type') || '';
const isM3u8 = url.includes('.m3u8') ||
let isM3u8 = url.includes('.m3u8') ||
contentType.includes('mpegurl') ||
contentType.includes('x-mpegURL');
// If content-type is ambiguous, check the response body for M3U header
if (!isM3u8 && (contentType.includes('text/plain') || contentType.includes('application/octet-stream') || !contentType)) {
const text = await response.text();
if (text.trimStart().startsWith('#EXTM3U') || text.trimStart().startsWith('#EXT-X-')) {
isM3u8 = true;
}
// For detected M3U8 from body check, process inline
if (isM3u8) {
const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`;
const rewritten = rewriteM3u8(text, url, proxyBase);
return new NextResponse(rewritten, {
status: 200,
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
},
});
}
// Not M3U8, return original text as binary-like response
return new NextResponse(text, {
status: response.status,
headers: {
'Content-Type': contentType || 'video/mp2t',
'Cache-Control': 'public, max-age=60',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
},
});
}
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
@@ -89,7 +133,7 @@ export async function GET(request: NextRequest) {
if (isM3u8) {
// Parse and rewrite manifest
const text = await response.text();
const proxyBase = `/api/iptv/stream?url=`;
const proxyBase = `/api/iptv/stream?${customUa ? `ua=${encodeURIComponent(customUa)}&` : ''}${customReferer ? `referer=${encodeURIComponent(customReferer)}&` : ''}url=`;
const rewritten = rewriteM3u8(text, url, proxyBase);
return new NextResponse(rewritten, {