mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
feat: implement Upstash Redis for user config sync, replacing file-based persistence
This commit is contained in:
@@ -42,6 +42,3 @@ next-env.d.ts
|
||||
|
||||
# unused/generated files
|
||||
contrast-test-results.json
|
||||
|
||||
# user data (server-side config sync)
|
||||
/.data/
|
||||
|
||||
@@ -175,11 +175,25 @@
|
||||
|
||||
### 跨设备配置同步
|
||||
|
||||
- **服务端存储**:视频源、订阅、显示设置等自动同步到服务端(文件存储,无需 Redis)
|
||||
- **自动拉取**:打开应用时自动从服务端拉取最新配置
|
||||
- **自动推送**:设置变更后自动延迟推送到服务端(防抖 3 秒)
|
||||
- **多设备支持**:电脑端配置的源和设置,手机端打开即可使用
|
||||
- **PWA 兼容**:iOS PWA 模式下配置不再丢失
|
||||
解决 iOS Safari「添加到主屏幕」后 PWA 与浏览器之间 localStorage 不共享的问题([#119](https://github.com/KuekHaoYang/KVideo/issues/119)),同时实现多设备配置共享([#115](https://github.com/KuekHaoYang/KVideo/issues/115))。
|
||||
|
||||
**工作原理:**
|
||||
|
||||
1. **服务端存储(Upstash Redis)**:用户配置通过 `/api/user/config` API 存储在 Upstash Redis 中,使用 `user:config:{profileId}` 作为 key。Edge Runtime 兼容,Cloudflare Pages / Vercel 均可部署。
|
||||
2. **自动拉取(Pull)**:应用加载时,`useConfigSync` hook 从服务端拉取配置,与本地 `updatedAt` 时间戳比较——服务端更新时自动合并到本地。
|
||||
3. **自动推送(Push)**:本地设置变更后,自动延迟 3 秒推送到服务端(防抖),避免频繁写入。
|
||||
4. **同步范围**:视频源 (`sources`)、高级源 (`premiumSources`)、订阅列表 (`subscriptions`)、屏蔽分类 (`blockedCategories`)、排序偏好 (`sortBy`)、语言 (`locale`)。
|
||||
5. **数据隔离**:按 `profileId`(SHA-256 哈希)隔离,不同账户互不影响。
|
||||
|
||||
**使用前提:**
|
||||
|
||||
- 需配置 Upstash Redis 环境变量(`UPSTASH_REDIS_REST_URL` 和 `UPSTASH_REDIS_REST_TOKEN`),与观看历史/收藏同步共用同一 Redis 实例。
|
||||
- 未配置 Redis 时,配置同步功能静默降级——应用正常运行,仅本地存储生效。
|
||||
|
||||
**典型场景:**
|
||||
|
||||
- 电脑浏览器配置了视频源 → 手机打开同一实例 → 自动拉取到相同配置
|
||||
- iOS Safari「添加到主屏幕」后打开 PWA → 自动从 Redis 同步配置,无需重新设置
|
||||
|
||||
### 无障碍设计
|
||||
|
||||
@@ -597,6 +611,8 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
| `AD_KEYWORDS` / `NEXT_PUBLIC_AD_KEYWORDS` | 广告过滤关键词 | - |
|
||||
| `AD_KEYWORDS_FILE` | 广告关键词文件路径 | - |
|
||||
| `NEXT_PUBLIC_DANMAKU_API_URL` | 弹幕聚合 API 地址 | - |
|
||||
| `UPSTASH_REDIS_REST_URL` | Upstash Redis REST URL(跨设备同步:配置、历史、收藏) | - |
|
||||
| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis REST Token | - |
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -604,7 +620,7 @@ docker run -e PORT=8080 -p 8080:8080 --name kvideo kuekhaoyang/kvideo:latest
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| **[Next.js](https://nextjs.org/)** | 16.1.6 | React 框架,使用 App Router |
|
||||
| **[Next.js](https://nextjs.org/)** | 16.1.7 | React 框架,使用 App Router |
|
||||
| **[React](https://react.dev/)** | 19.2.4 | UI 组件库 |
|
||||
| **[TypeScript](https://www.typescriptlang.org/)** | 5.x | 类型安全的 JavaScript |
|
||||
| **[Tailwind CSS](https://tailwindcss.com/)** | 4.x | 实用优先的 CSS 框架 |
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
/**
|
||||
* User Config Sync API Route
|
||||
* File-based settings persistence for cross-device and PWA support.
|
||||
* Stores user settings (sources, IPTV, display preferences) server-side
|
||||
* User Config Sync API Route (Edge Runtime)
|
||||
* Upstash Redis-backed settings persistence for cross-device and PWA support.
|
||||
* Stores user settings (sources, display preferences) server-side
|
||||
* so they persist across browsers, devices, and PWA installs.
|
||||
*
|
||||
* No external dependencies required — uses local JSON files.
|
||||
*/
|
||||
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '.data', 'user-config');
|
||||
export const runtime = 'edge';
|
||||
|
||||
async function ensureDir() {
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const redis = Redis.fromEnv();
|
||||
|
||||
function getFilePath(profileId: string): string {
|
||||
// Sanitize profileId to prevent path traversal
|
||||
function redisKey(profileId: string): string {
|
||||
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
return path.join(DATA_DIR, `${safe}.json`);
|
||||
return `user:config:${safe}`;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -31,15 +25,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureDir();
|
||||
const filePath = getFilePath(profileId);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return NextResponse.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return NextResponse.json({ success: true, data: null });
|
||||
}
|
||||
const data = await redis.get(redisKey(profileId));
|
||||
return NextResponse.json({ success: true, data: data || null });
|
||||
} catch (error) {
|
||||
console.error('Config read error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read config' },
|
||||
@@ -56,21 +44,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureDir();
|
||||
const body = await request.json();
|
||||
const filePath = getFilePath(profileId);
|
||||
const key = redisKey(profileId);
|
||||
|
||||
// Merge with existing data if present
|
||||
let existing: any = {};
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
existing = JSON.parse(content);
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
const existing = (await redis.get(key)) as Record<string, any> | null;
|
||||
const merged = { ...(existing || {}), ...body, updatedAt: Date.now() };
|
||||
|
||||
const merged = { ...existing, ...body, updatedAt: Date.now() };
|
||||
await fs.writeFile(filePath, JSON.stringify(merged, null, 2), 'utf-8');
|
||||
await redis.set(key, merged);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user