mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-23 03:04:43 +08:00
feat: save play record using localstorage
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
import { openGraph } from '@/lib/og';
|
||||
|
||||
describe('Open Graph function should work correctly', () => {
|
||||
it('should not return templateTitle when not specified', () => {
|
||||
const result = openGraph({
|
||||
description: 'Test description',
|
||||
siteName: 'Test site name',
|
||||
});
|
||||
expect(result).not.toContain('&templateTitle=');
|
||||
});
|
||||
|
||||
it('should return templateTitle when specified', () => {
|
||||
const result = openGraph({
|
||||
templateTitle: 'Test Template Title',
|
||||
description: 'Test description',
|
||||
siteName: 'Test site name',
|
||||
});
|
||||
expect(result).toContain('&templateTitle=Test%20Template%20Title');
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,23 @@ export interface ApiSite {
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: 'localstorage' | 'database';
|
||||
database?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
cache_time?: number;
|
||||
api_site: {
|
||||
[key: string]: ApiSite;
|
||||
};
|
||||
storage?: StorageConfig;
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
@@ -62,3 +74,8 @@ export function getApiSites(): ApiSite[] {
|
||||
key,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getStorageConfig(): StorageConfig {
|
||||
const config = getConfig();
|
||||
return config.storage || { type: 'localstorage' };
|
||||
}
|
||||
|
||||
166
src/lib/db.client.ts
Normal file
166
src/lib/db.client.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable no-console */
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 仅在浏览器端使用的数据库工具,目前基于 localStorage 实现。
|
||||
* 之所以单独拆分文件,是为了避免在客户端 bundle 中引入 `fs`, `path` 等 Node.js 内置模块,
|
||||
* 从而解决诸如 "Module not found: Can't resolve 'fs'" 的问题。
|
||||
*
|
||||
* 功能:
|
||||
* 1. 获取全部播放记录(getAllPlayRecords)。
|
||||
* 2. 保存播放记录(savePlayRecord)。
|
||||
*
|
||||
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||
*/
|
||||
|
||||
// ---- 类型 ----
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
cover: string;
|
||||
index: number; // 第几集
|
||||
total_episodes: number; // 总集数
|
||||
play_time: number; // 播放进度(秒)
|
||||
total_time: number; // 总进度(秒)
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
user_id: number; // 用户 ID,本地存储情况下恒为 0
|
||||
}
|
||||
|
||||
// ---- 常量 ----
|
||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||
|
||||
// ---- 环境变量 ----
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'database'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// ---- 工具函数 ----
|
||||
async function fetchFromApi<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
if (!res.ok) throw new Error(`请求 ${path} 失败: ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成存储key
|
||||
*/
|
||||
export function generateStorageKey(source: string, id: string): string {
|
||||
return `${source}+${id}`;
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
/**
|
||||
* 读取 localStorage 中的全部播放记录。
|
||||
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
||||
*/
|
||||
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||
// 若配置标明使用数据库,则从后端 API 拉取
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
return fetchFromApi<Record<string, PlayRecord>>('/api/playrecords');
|
||||
}
|
||||
|
||||
// 默认 / localstorage 流程
|
||||
if (typeof window === 'undefined') {
|
||||
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, PlayRecord>;
|
||||
} catch (err) {
|
||||
console.error('读取播放记录失败:', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放记录到 localStorage 或通过 API 保存到数据库
|
||||
*/
|
||||
export async function savePlayRecord(
|
||||
source: string,
|
||||
id: string,
|
||||
record: Omit<PlayRecord, 'user_id'>
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
const fullRecord: PlayRecord = { ...record, user_id: 0 };
|
||||
|
||||
// 若配置标明使用数据库,则通过 API 保存
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
try {
|
||||
const res = await fetch('/api/playrecords', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, record: fullRecord }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`保存播放记录失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error('保存播放记录到数据库失败:', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认 / localstorage 流程
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端保存播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
allRecords[key] = fullRecord;
|
||||
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
} catch (err) {
|
||||
console.error('保存播放记录失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除播放记录
|
||||
*/
|
||||
export async function deletePlayRecord(
|
||||
source: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
|
||||
// 若配置标明使用数据库,则通过 API 删除
|
||||
if (STORAGE_TYPE === 'database') {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/playrecords?key=${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
|
||||
} catch (err) {
|
||||
console.error('删除播放记录到数据库失败:', err);
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认 / localstorage 流程
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端删除播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllPlayRecords();
|
||||
delete allRecords[key];
|
||||
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
console.log('播放记录已删除:', key);
|
||||
} catch (err) {
|
||||
console.error('删除播放记录失败:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
198
src/lib/db.ts
Normal file
198
src/lib/db.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { getStorageConfig } from './config';
|
||||
|
||||
// 播放记录数据结构
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
cover: string;
|
||||
index: number; // 第几集
|
||||
total_episodes: number; // 总集数
|
||||
play_time: number; // 播放进度(秒)
|
||||
total_time: number; // 总进度(秒)
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
user_id: number; // 用户ID,localStorage情况下全部为0
|
||||
}
|
||||
|
||||
// 收藏数据结构
|
||||
export interface Favorite {
|
||||
source_name: string;
|
||||
total_episodes: number; // 总集数
|
||||
title: string;
|
||||
cover: string;
|
||||
user_id: number; // 用户ID,localStorage情况下全部为0
|
||||
}
|
||||
|
||||
// 存储接口
|
||||
export interface IStorage {
|
||||
// 播放记录相关
|
||||
getPlayRecord(key: string): Promise<PlayRecord | null>;
|
||||
setPlayRecord(key: string, record: PlayRecord): Promise<void>;
|
||||
getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }>;
|
||||
deletePlayRecord(key: string): Promise<void>;
|
||||
|
||||
// 收藏相关
|
||||
getFavorite(key: string): Promise<Favorite | null>;
|
||||
setFavorite(key: string, favorite: Favorite): Promise<void>;
|
||||
getAllFavorites(): Promise<{ [key: string]: Favorite }>;
|
||||
deleteFavorite(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
// 数据库实现(保留接口,待实现)
|
||||
class DatabaseStorage implements IStorage {
|
||||
async getPlayRecord(_key: string): Promise<PlayRecord | null> {
|
||||
// TODO: 实现数据库查询逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async setPlayRecord(_key: string, _record: PlayRecord): Promise<void> {
|
||||
// TODO: 实现数据库插入/更新逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }> {
|
||||
// TODO: 实现数据库查询所有记录逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async deletePlayRecord(_key: string): Promise<void> {
|
||||
// TODO: 实现数据库删除逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async getFavorite(_: string): Promise<Favorite | null> {
|
||||
// TODO: 实现数据库查询逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async setFavorite(_key: string, _favorite: Favorite): Promise<void> {
|
||||
// TODO: 实现数据库插入/更新逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async getAllFavorites(): Promise<{ [key: string]: Favorite }> {
|
||||
// TODO: 实现数据库查询所有收藏逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
|
||||
async deleteFavorite(_key: string): Promise<void> {
|
||||
// TODO: 实现数据库删除逻辑
|
||||
throw new Error('Database storage not implemented yet');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建存储实例
|
||||
function createStorage(): IStorage {
|
||||
const config = getStorageConfig();
|
||||
|
||||
switch (config.type) {
|
||||
case 'database':
|
||||
return new DatabaseStorage();
|
||||
case 'localstorage':
|
||||
default:
|
||||
return null as unknown as IStorage;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例存储实例
|
||||
let storageInstance: IStorage | null = null;
|
||||
|
||||
export function getStorage(): IStorage {
|
||||
if (!storageInstance) {
|
||||
storageInstance = createStorage();
|
||||
}
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
// 工具函数:生成存储key
|
||||
export function generateStorageKey(source: string, id: string): string {
|
||||
return `${source}+${id}`;
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
export class DbManager {
|
||||
private storage: IStorage;
|
||||
|
||||
constructor() {
|
||||
this.storage = getStorage();
|
||||
}
|
||||
|
||||
// 播放记录相关方法
|
||||
async getPlayRecord(source: string, id: string): Promise<PlayRecord | null> {
|
||||
const key = generateStorageKey(source, id);
|
||||
return this.storage.getPlayRecord(key);
|
||||
}
|
||||
|
||||
async savePlayRecord(
|
||||
source: string,
|
||||
id: string,
|
||||
record: Omit<PlayRecord, 'user_id'>
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
const fullRecord: PlayRecord = { ...record, user_id: 0 };
|
||||
await this.storage.setPlayRecord(key, fullRecord);
|
||||
}
|
||||
|
||||
async getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }> {
|
||||
return this.storage.getAllPlayRecords();
|
||||
}
|
||||
|
||||
async deletePlayRecord(source: string, id: string): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.deletePlayRecord(key);
|
||||
}
|
||||
|
||||
// 收藏相关方法
|
||||
async getFavorite(source: string, id: string): Promise<Favorite | null> {
|
||||
const key = generateStorageKey(source, id);
|
||||
return this.storage.getFavorite(key);
|
||||
}
|
||||
|
||||
async saveFavorite(
|
||||
source: string,
|
||||
id: string,
|
||||
favorite: Omit<Favorite, 'user_id'>
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
const fullFavorite: Favorite = { ...favorite, user_id: 0 };
|
||||
await this.storage.setFavorite(key, fullFavorite);
|
||||
}
|
||||
|
||||
async getAllFavorites(): Promise<{ [key: string]: Favorite }> {
|
||||
return this.storage.getAllFavorites();
|
||||
}
|
||||
|
||||
async deleteFavorite(source: string, id: string): Promise<void> {
|
||||
const key = generateStorageKey(source, id);
|
||||
await this.storage.deleteFavorite(key);
|
||||
}
|
||||
|
||||
async isFavorited(source: string, id: string): Promise<boolean> {
|
||||
const favorite = await this.getFavorite(source, id);
|
||||
return favorite !== null;
|
||||
}
|
||||
|
||||
async toggleFavorite(
|
||||
source: string,
|
||||
id: string,
|
||||
favoriteData?: Omit<Favorite, 'user_id'>
|
||||
): Promise<boolean> {
|
||||
const isFav = await this.isFavorited(source, id);
|
||||
|
||||
if (isFav) {
|
||||
await this.deleteFavorite(source, id);
|
||||
return false;
|
||||
} else {
|
||||
if (favoriteData) {
|
||||
await this.saveFavorite(source, id, favoriteData);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error('Favorite data is required when adding to favorites');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const db = new DbManager();
|
||||
@@ -8,6 +8,7 @@ import { z } from 'zod';
|
||||
|
||||
const envVariables = z.object({
|
||||
NEXT_PUBLIC_SHOW_LOGGER: z.enum(['true', 'false']).optional(),
|
||||
NEXT_PUBLIC_STORAGE_TYPE: z.enum(['localstorage', 'database']).optional(),
|
||||
});
|
||||
|
||||
envVariables.parse(process.env);
|
||||
|
||||
Reference in New Issue
Block a user