mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: add support for environment variable defined subscription sources and runtime config API
This commit is contained in:
@@ -117,29 +117,40 @@ docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_secret_password --name kvideo
|
||||
- **无法在界面删除**:只能通过修改环境变量更改
|
||||
- **与本地密码兼容**:两种密码都可以解锁应用
|
||||
|
||||
## � 自动订阅源配置
|
||||
## 🔄 自动订阅源配置
|
||||
|
||||
可以通过环境变量 `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` 自动配置订阅源,应用启动时会自动加载并设置为自动更新。
|
||||
可以通过环境变量 `SUBSCRIPTION_SOURCES` 或 `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` 自动配置订阅源。在 Docker 或 Cloudflare 部署中,这些变量在运行时生效,应用启动后会自动同步。
|
||||
|
||||
**格式:** JSON 数组字符串,包含 `name` 和 `url` 字段。
|
||||
### 支持格式
|
||||
|
||||
**示例:**
|
||||
1. **JSON 数组字符串 (推荐):** 包含 `name` 和 `url` 字段。
|
||||
```bash
|
||||
SUBSCRIPTION_SOURCES='[{"name":"每日更新源","url":"https://example.com/api.json"}]'
|
||||
```
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUBSCRIPTION_SOURCES='[{"name":"每日更新源","url":"https://example.com/api.json"},{"name":"备用源","url":"https://backup.com/api.json"}]'
|
||||
```
|
||||
2. **简单 URL (单个或多个):** 直接提供 URL,多个 URL 用逗号分隔。应用会自动生成默认名称。
|
||||
```bash
|
||||
SUBSCRIPTION_SOURCES='https://example.com/api.json,https://backup.com/api.json'
|
||||
```
|
||||
|
||||
### 部署示例
|
||||
|
||||
**Docker 部署:**
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 -e NEXT_PUBLIC_SUBSCRIPTION_SOURCES='[{"name":"MySource","url":"..."}]' --name kvideo kuekhaoyang/kvideo:latest
|
||||
docker run -d -p 3000:3000 \
|
||||
-e SUBSCRIPTION_SOURCES='https://example.com/api.json' \
|
||||
--name kvideo kuekhaoyang/kvideo:latest
|
||||
```
|
||||
|
||||
**Vercel 部署:**
|
||||
**Cloudflare / Vercel 部署:**
|
||||
|
||||
在 Vercel 项目设置中添加环境变量:
|
||||
- 变量名:`NEXT_PUBLIC_SUBSCRIPTION_SOURCES`
|
||||
- 变量值:`[{"name":"...","url":"..."}]`
|
||||
在项目设置中添加环境变量:
|
||||
- 变量名:`SUBSCRIPTION_SOURCES`
|
||||
- 变量值:你的订阅地址或 JSON 字符串
|
||||
|
||||
> [!TIP]
|
||||
> 现在应用支持**运行时配置**。在 Docker 中修改环境变量并重启容器即可生效,无需重新构建镜像。
|
||||
|
||||
## 📝 自定义源 JSON 格式
|
||||
|
||||
|
||||
@@ -5,19 +5,23 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'edge';
|
||||
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
|
||||
export async function GET() {
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
|
||||
return NextResponse.json({
|
||||
hasEnvPassword: ACCESS_PASSWORD.length > 0,
|
||||
subscriptionSources: SUBSCRIPTION_SOURCES,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { password } = await request.json();
|
||||
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
|
||||
|
||||
if (!ACCESS_PASSWORD) {
|
||||
return NextResponse.json({ valid: false, message: 'No env password set' });
|
||||
|
||||
@@ -22,20 +22,37 @@ export function useSettingsPage() {
|
||||
const [passwordAccess, setPasswordAccess] = useState(false);
|
||||
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
|
||||
const [envPasswordSet, setEnvPasswordSet] = useState(false);
|
||||
const [envSubscriptionsSet, setEnvSubscriptionsSet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources || []);
|
||||
setSubscriptions(settings.subscriptions || []);
|
||||
setSortBy(settings.sortBy);
|
||||
setPasswordAccess(settings.passwordAccess);
|
||||
setAccessPasswords(settings.accessPasswords);
|
||||
const updateFromStore = () => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setSources(settings.sources || []);
|
||||
setSubscriptions(settings.subscriptions || []);
|
||||
setSortBy(settings.sortBy);
|
||||
setPasswordAccess(settings.passwordAccess);
|
||||
setAccessPasswords(settings.accessPasswords);
|
||||
};
|
||||
|
||||
// Fetch env password status
|
||||
// Initial load
|
||||
updateFromStore();
|
||||
|
||||
// Subscribe to changes
|
||||
const unsubscribe = settingsStore.subscribe(updateFromStore);
|
||||
|
||||
// Fetch env status
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(data => setEnvPasswordSet(data.hasEnvPassword))
|
||||
.catch(() => setEnvPasswordSet(false));
|
||||
.then(data => {
|
||||
setEnvPasswordSet(data.hasEnvPassword);
|
||||
setEnvSubscriptionsSet(!!data.subscriptionSources);
|
||||
})
|
||||
.catch(() => {
|
||||
setEnvPasswordSet(false);
|
||||
setEnvSubscriptionsSet(false);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const handleSourcesChange = (newSources: VideoSource[]) => {
|
||||
@@ -274,6 +291,7 @@ export function useSettingsPage() {
|
||||
passwordAccess,
|
||||
accessPasswords,
|
||||
envPasswordSet,
|
||||
envSubscriptionsSet,
|
||||
isAddModalOpen,
|
||||
isExportModalOpen,
|
||||
isImportModalOpen,
|
||||
|
||||
@@ -38,6 +38,7 @@ export default function SettingsPage() {
|
||||
handleExport,
|
||||
handleImportFile,
|
||||
handleImportLink,
|
||||
envSubscriptionsSet,
|
||||
subscriptions,
|
||||
handleAddSubscription,
|
||||
handleRemoveSubscription,
|
||||
@@ -75,6 +76,7 @@ export default function SettingsPage() {
|
||||
setIsAddModalOpen(true);
|
||||
}}
|
||||
onEditSource={handleEditSource}
|
||||
envSubscriptionsSet={envSubscriptionsSet}
|
||||
/>
|
||||
|
||||
{/* Sort Options */}
|
||||
@@ -118,6 +120,7 @@ export default function SettingsPage() {
|
||||
onAddSubscription={handleAddSubscription}
|
||||
onRemoveSubscription={handleRemoveSubscription}
|
||||
onRefreshSubscription={handleRefreshSubscription}
|
||||
envSubscriptionsSet={envSubscriptionsSet}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -21,8 +21,11 @@ interface ImportModalProps {
|
||||
onAddSubscription: (sub: SourceSubscription) => Promise<boolean> | boolean;
|
||||
onRemoveSubscription: (id: string) => void;
|
||||
onRefreshSubscription: (sub: SourceSubscription) => Promise<void>;
|
||||
envSubscriptionsSet?: boolean;
|
||||
}
|
||||
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
export function ImportModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -31,7 +34,8 @@ export function ImportModal({
|
||||
subscriptions,
|
||||
onAddSubscription,
|
||||
onRemoveSubscription,
|
||||
onRefreshSubscription
|
||||
onRefreshSubscription,
|
||||
envSubscriptionsSet
|
||||
}: ImportModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription'>('file');
|
||||
|
||||
@@ -51,8 +55,8 @@ export function ImportModal({
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 flex flex-col max-h-[85vh]">
|
||||
@@ -73,6 +77,20 @@ export function ImportModal({
|
||||
|
||||
<ImportModalTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{envSubscriptionsSet && (
|
||||
<div className="flex items-center gap-3 p-3 mb-4 bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] border border-[var(--accent-color)]/30 rounded-[var(--radius-xl)] animate-in fade-in slide-in-from-top-2">
|
||||
<ShieldCheck className="text-[var(--accent-color)] shrink-0" size={20} />
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[var(--text-color)]">
|
||||
系统预设源已启用
|
||||
</p>
|
||||
<p className="text-[10px] text-[var(--text-color-secondary)] leading-tight">
|
||||
通过环境变量设置,应用已自动同步最新预设源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === 'file' && (
|
||||
<FileImportTab onImport={onImportFile} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { SourceManager } from '@/components/settings/SourceManager';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { DEFAULT_SOURCES } from '@/lib/api/default-sources';
|
||||
@@ -9,6 +10,7 @@ interface SourceSettingsProps {
|
||||
onRestoreDefaults: () => void;
|
||||
onAddSource: () => void;
|
||||
onEditSource?: (source: VideoSource) => void;
|
||||
envSubscriptionsSet?: boolean;
|
||||
}
|
||||
|
||||
export function SourceSettings({
|
||||
@@ -17,6 +19,7 @@ export function SourceSettings({
|
||||
onRestoreDefaults,
|
||||
onAddSource,
|
||||
onEditSource,
|
||||
envSubscriptionsSet,
|
||||
}: SourceSettingsProps) {
|
||||
const [showAllSources, setShowAllSources] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -80,6 +83,21 @@ export function SourceSettings({
|
||||
管理视频来源,调整优先级和启用状态
|
||||
</p>
|
||||
|
||||
{/* Env Subscriptions Notice */}
|
||||
{envSubscriptionsSet && (
|
||||
<div className="flex items-center gap-3 p-4 mb-6 bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] border border-[var(--accent-color)]/30 rounded-[var(--radius-2xl)] animate-in fade-in slide-in-from-top-2">
|
||||
<ShieldCheck className="text-[var(--accent-color)] shrink-0" size={24} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-color)]">
|
||||
系统预设源已启用
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">
|
||||
发现环境变量配置,应用已自动同步最新预设源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { settingsStore, parseEnvSubscriptions } from '@/lib/store/settings-store';
|
||||
import { fetchSourcesFromUrl, mergeSources } from '@/lib/utils/source-import-utils';
|
||||
|
||||
export function useSubscriptionSync() {
|
||||
@@ -10,12 +10,52 @@ export function useSubscriptionSync() {
|
||||
hasSyncedRef.current = true;
|
||||
|
||||
const sync = async () => {
|
||||
const settings = settingsStore.getSettings();
|
||||
let settings = settingsStore.getSettings();
|
||||
let anyChanged = false;
|
||||
|
||||
// Fetch runtime config for subscription sources (to support Docker)
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
const config = await res.json();
|
||||
if (config.subscriptionSources) {
|
||||
const runtimeSubs = parseEnvSubscriptions(config.subscriptionSources);
|
||||
|
||||
// Merge runtime subscriptions if not already in settings
|
||||
const currentSubs = [...settings.subscriptions];
|
||||
let subsAdded = false;
|
||||
|
||||
runtimeSubs.forEach(rSub => {
|
||||
const exists = currentSubs.some(s => s.url === rSub.url);
|
||||
if (!exists) {
|
||||
currentSubs.push({
|
||||
...rSub,
|
||||
autoRefresh: true
|
||||
});
|
||||
subsAdded = true;
|
||||
anyChanged = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (subsAdded) {
|
||||
settings = {
|
||||
...settings,
|
||||
subscriptions: currentSubs
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch runtime config', e);
|
||||
}
|
||||
|
||||
const subscriptions = settings.subscriptions.filter(s => s.autoRefresh !== false);
|
||||
|
||||
if (subscriptions.length === 0) return;
|
||||
if (subscriptions.length === 0) {
|
||||
if (anyChanged) {
|
||||
settingsStore.saveSettings(settings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let anyChanged = false;
|
||||
let currentSources = [...settings.sources];
|
||||
let currentAdultSources = [...settings.adultSources];
|
||||
let updatedSubscriptions = [...settings.subscriptions];
|
||||
|
||||
+24
-15
@@ -44,17 +44,14 @@ export const getDefaultAdultSources = (): VideoSource[] => ADULT_SOURCES;
|
||||
|
||||
|
||||
|
||||
function getEnvSubscriptions(): SourceSubscription[] {
|
||||
if (typeof process === 'undefined' || !process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const envValue = process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES.trim();
|
||||
export function parseEnvSubscriptions(envValue: string | undefined): SourceSubscription[] {
|
||||
if (!envValue) return [];
|
||||
const trimmed = envValue.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
// 1. Try JSON
|
||||
try {
|
||||
const raw = JSON.parse(envValue);
|
||||
const raw = JSON.parse(trimmed);
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.filter((item: any) => item && typeof item.name === 'string' && typeof item.url === 'string')
|
||||
@@ -65,11 +62,9 @@ function getEnvSubscriptions(): SourceSubscription[] {
|
||||
}
|
||||
|
||||
// 2. Try Simple URL (or comma separated)
|
||||
// Check if it looks like a URL (basic check)
|
||||
if (envValue.includes('http')) {
|
||||
const urls = envValue.split(',').map(u => u.trim()).filter(u => u.length > 0);
|
||||
if (trimmed.includes('http')) {
|
||||
const urls = trimmed.split(',').map(u => u.trim()).filter(u => u.length > 0);
|
||||
return urls.map((url, index) => {
|
||||
// Basic URL validation
|
||||
if (!url.startsWith('http')) return null;
|
||||
|
||||
const name = urls.length > 1
|
||||
@@ -82,6 +77,14 @@ function getEnvSubscriptions(): SourceSubscription[] {
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getEnvSubscriptions(): SourceSubscription[] {
|
||||
if (typeof process === 'undefined' || !process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parseEnvSubscriptions(process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES);
|
||||
}
|
||||
// Debugging helper
|
||||
// console.log("Environment Subscriptions:", getEnvSubscriptions());
|
||||
|
||||
@@ -157,11 +160,17 @@ export const settingsStore = {
|
||||
});
|
||||
|
||||
// Filter out invalid sources (missing baseUrl etc)
|
||||
const validSources = (Array.isArray(parsed.sources) ? parsed.sources : getDefaultSources())
|
||||
.filter((s: any) => s && s.id && s.name && s.baseUrl);
|
||||
const parsedSources = Array.isArray(parsed.sources) && parsed.sources.length > 0
|
||||
? parsed.sources
|
||||
: getDefaultSources();
|
||||
|
||||
const validAdultSources = (Array.isArray(parsed.adultSources) ? parsed.adultSources : getDefaultAdultSources())
|
||||
.filter((s: any) => s && s.id && s.name && s.baseUrl);
|
||||
const validSources = parsedSources.filter((s: any) => s && s.id && s.name && s.baseUrl);
|
||||
|
||||
const parsedAdultSources = Array.isArray(parsed.adultSources) && parsed.adultSources.length > 0
|
||||
? parsed.adultSources
|
||||
: getDefaultAdultSources();
|
||||
|
||||
const validAdultSources = parsedAdultSources.filter((s: any) => s && s.id && s.name && s.baseUrl);
|
||||
|
||||
// Validate that parsed data has all required properties
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user