From 48d6568a7077020e53eef63545ed137d5405f596 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 25 Dec 2025 16:25:53 +0800 Subject: [PATCH] feat: add support for environment variable defined subscription sources and runtime config API --- README.md | 35 ++++++++++++------- app/api/config/route.ts | 8 +++-- app/settings/hooks/useSettingsPage.ts | 36 ++++++++++++++----- app/settings/page.tsx | 3 ++ components/settings/ImportModal.tsx | 24 +++++++++++-- components/settings/SourceSettings.tsx | 18 ++++++++++ lib/hooks/useSubscriptionSync.ts | 48 +++++++++++++++++++++++--- lib/store/settings-store.ts | 39 +++++++++++++-------- 8 files changed, 166 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index f6dc10e..1d6d4c1 100644 --- a/README.md +++ b/README.md @@ -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 格式 diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 97a4772..b596f22 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -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' }); diff --git a/app/settings/hooks/useSettingsPage.ts b/app/settings/hooks/useSettingsPage.ts index 5bb7854..862e920 100644 --- a/app/settings/hooks/useSettingsPage.ts +++ b/app/settings/hooks/useSettingsPage.ts @@ -22,20 +22,37 @@ export function useSettingsPage() { const [passwordAccess, setPasswordAccess] = useState(false); const [accessPasswords, setAccessPasswords] = useState([]); 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, diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 3df7ca6..2049639 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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} /> Promise | boolean; onRemoveSubscription: (id: string) => void; onRefreshSubscription: (sub: SourceSubscription) => Promise; + 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 */}
@@ -73,6 +77,20 @@ export function ImportModal({ + {envSubscriptionsSet && ( +
+ +
+

+ 系统预设源已启用 +

+

+ 通过环境变量设置,应用已自动同步最新预设源 +

+
+
+ )} +
{activeTab === 'file' && ( diff --git a/components/settings/SourceSettings.tsx b/components/settings/SourceSettings.tsx index e0e32f6..0b59deb 100644 --- a/components/settings/SourceSettings.tsx +++ b/components/settings/SourceSettings.tsx @@ -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({ 管理视频来源,调整优先级和启用状态

+ {/* Env Subscriptions Notice */} + {envSubscriptionsSet && ( +
+ +
+

+ 系统预设源已启用 +

+

+ 发现环境变量配置,应用已自动同步最新预设源 +

+
+
+ )} + {/* Search Bar */}
{ - 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]; diff --git a/lib/store/settings-store.ts b/lib/store/settings-store.ts index 9f77af6..0d48cf8 100644 --- a/lib/store/settings-store.ts +++ b/lib/store/settings-store.ts @@ -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 {