feat: add support for environment variable defined subscription sources and runtime config API

This commit is contained in:
kuekhaoyang
2025-12-25 16:25:53 +08:00
parent 609fba7571
commit 48d6568a70
8 changed files with 166 additions and 45 deletions
+23 -12
View File
@@ -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 格式
+6 -2
View File
@@ -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' });
+27 -9
View File
@@ -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,
+3
View File
@@ -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 -3
View File
@@ -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} />
+18
View File
@@ -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
+44 -4
View File
@@ -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
View File
@@ -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 {