From e45feeb1a808b69a979ce99d3d5989e670d3cc0b Mon Sep 17 00:00:00 2001 From: shinya Date: Thu, 19 Jun 2025 21:00:47 +0800 Subject: [PATCH] feat: save play record using localstorage --- config.example.json | 5 +- next.config.js | 23 ++++ src/app/api/playrecords/route.ts | 72 ++++++++++ src/app/page.tsx | 90 +------------ src/app/play/page.tsx | 181 ++++++++++++++++++++++++- src/components/ContinueWatching.tsx | 110 ++++++++++++++++ src/components/VideoCard.tsx | 11 ++ src/lib/__tests__/og.test.ts | 20 --- src/lib/config.ts | 17 +++ src/lib/db.client.ts | 166 +++++++++++++++++++++++ src/lib/db.ts | 198 ++++++++++++++++++++++++++++ src/lib/env.ts | 1 + 12 files changed, 782 insertions(+), 112 deletions(-) create mode 100644 src/app/api/playrecords/route.ts create mode 100644 src/components/ContinueWatching.tsx delete mode 100644 src/lib/__tests__/og.test.ts create mode 100644 src/lib/db.client.ts create mode 100644 src/lib/db.ts diff --git a/config.example.json b/config.example.json index dd7aff1..2297f94 100644 --- a/config.example.json +++ b/config.example.json @@ -1,4 +1,7 @@ { "cache_time": 7200, - "api_site": {} + "api_site": {}, + "storage": { + "type": "localstorage" + } } diff --git a/next.config.js b/next.config.js index 571b5e4..152688e 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,5 @@ /** @type {import('next').NextConfig} */ +/* eslint-disable @typescript-eslint/no-var-requires */ const nextConfig = { eslint: { dirs: ['src'], @@ -7,6 +8,28 @@ const nextConfig = { reactStrictMode: true, swcMinify: true, + /** + * 在编译阶段把 storage.type 写入环境变量,供浏览器端动态切换存储方案。 + */ + env: (function () { + const fs = require('fs'); + const path = require('path'); + + let storageType = 'localstorage'; + try { + const json = JSON.parse( + fs.readFileSync(path.join(__dirname, 'config.json'), 'utf-8') + ); + storageType = json?.storage?.type ?? 'localstorage'; + } catch { + // ignore – 保持默认值 + } + + return { + NEXT_PUBLIC_STORAGE_TYPE: storageType, + }; + })(), + // Uncoment to add domain whitelist images: { remotePatterns: [ diff --git a/src/app/api/playrecords/route.ts b/src/app/api/playrecords/route.ts new file mode 100644 index 0000000..f0f5d6d --- /dev/null +++ b/src/app/api/playrecords/route.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { db } from '@/lib/db'; +import { PlayRecord } from '@/lib/db'; + +export async function GET() { + try { + const records = await db.getAllPlayRecords(); + return NextResponse.json(records, { status: 200 }); + } catch (err) { + console.error('获取播放记录失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { key, record }: { key: string; record: PlayRecord } = body; + + if (!key || !record) { + return NextResponse.json( + { error: 'Missing key or record' }, + { status: 400 } + ); + } + + // 验证播放记录数据 + if (!record.title || !record.source_name || record.index < 1) { + return NextResponse.json( + { error: 'Invalid record data' }, + { status: 400 } + ); + } + + // 从key中解析source和id + const [source, id] = key.split('+'); + if (!source || !id) { + return NextResponse.json( + { error: 'Invalid key format' }, + { status: 400 } + ); + } + + // 保存播放记录(不包含user_id,将由savePlayRecord自动添加) + const recordWithoutUserId = { + title: record.title, + source_name: record.source_name, + cover: record.cover, + index: record.index, + total_episodes: record.total_episodes, + play_time: record.play_time, + total_time: record.total_time, + save_time: record.save_time, + }; + + await db.savePlayRecord(source, id, recordWithoutUserId); + + return NextResponse.json({ success: true }, { status: 200 }); + } catch (err) { + console.error('保存播放记录失败', err); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9957f32..b12a6d6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,13 +13,10 @@ import { useEffect, useState } from 'react'; import CapsuleSwitch from '@/components/CapsuleSwitch'; import CollectionCard from '@/components/CollectionCard'; +import ContinueWatching from '@/components/ContinueWatching'; import DemoCard from '@/components/DemoCard'; import PageLayout from '@/components/layout/PageLayout'; import ScrollableRow from '@/components/ScrollableRow'; -import VideoCard from '@/components/VideoCard'; - -const defaultPoster = - 'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg'; interface DoubanItem { title: string; @@ -32,76 +29,6 @@ interface DoubanResponse { list: DoubanItem[]; } -// 模拟数据 -const mockData = { - recentMovies: [ - { - id: '1', - title: '流浪地球2', - poster: defaultPoster, - source: 'dyttzy', - source_name: '电影天堂', - }, - { - id: '2', - title: '满江红', - poster: defaultPoster, - source: 'dyttzy', - source_name: '电影天堂', - }, - ], - recentTvShows: [ - { - id: '3', - title: '三体', - poster: defaultPoster, - source: 'dyttzy', - source_name: '电影天堂', - episodes: 30, - }, - { - id: '4', - title: '狂飙', - poster: defaultPoster, - episodes: 39, - source: 'dyttzy', - source_name: '电影天堂', - }, - { - id: '332', - title: '三体', - poster: defaultPoster, - source: 'dyttzy', - source_name: '电影天堂', - episodes: 30, - }, - { - id: '4231', - title: '狂飙', - poster: defaultPoster, - episodes: 39, - source: 'dyttzy', - source_name: '电影天堂', - }, - { - id: '3342', - title: '三体', - poster: defaultPoster, - source: 'dyttzy', - source_name: '电影天堂', - episodes: 30, - }, - { - id: '8', - title: '狂飙', - poster: defaultPoster, - episodes: 39, - source: 'dyttzy', - source_name: '电影天堂', - }, - ], -}; - // 合集数据 const collections = [ { @@ -194,20 +121,7 @@ export default function Home() { {/* 继续观看 */} -
-

- 继续观看 -

- - {[...mockData.recentMovies, ...mockData.recentTvShows].map( - (item) => ( -
- -
- ) - )} -
-
+ {/* 热门电影 */}
diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index e8857c4..a2b6116 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -6,6 +6,8 @@ import { useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import React from 'react'; +import { deletePlayRecord, savePlayRecord } from '@/lib/db.client'; + import { VideoDetail } from '../api/detail/route'; // 动态导入 Artplayer 和 Hls 以避免 SSR 问题 @@ -69,6 +71,14 @@ export default function PlayPage() { // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); + // 播放进度保存相关 + const saveIntervalRef = useRef(null); + const lastSaveTimeRef = useRef(0); + const videoEventListenersRef = useRef<{ + video: HTMLVideoElement; + listeners: Array<{ event: string; handler: EventListener }>; + } | null>(null); + // 总集数:从 detail 中获取,保证随 detail 更新而变化 const totalEpisodes = detail?.episodes?.length || 0; @@ -142,6 +152,8 @@ export default function PlayPage() { setVideoTitle(data.videoInfo.title); setVideoCover(data.videoInfo.cover); setDetail(data); + + // 确保集数索引在有效范围内 if (currentEpisodeIndex >= data.episodes.length) { setCurrentEpisodeIndex(0); } @@ -162,6 +174,45 @@ export default function PlayPage() { fetchDetail(); }, [currentSource]); + const attachVideoEventListeners = (video: HTMLVideoElement) => { + if (!video) return; + + // 移除旧监听器(如果存在) + if (videoEventListenersRef.current) { + const { video: oldVideo, listeners } = videoEventListenersRef.current; + listeners.forEach(({ event, handler }) => { + oldVideo.removeEventListener(event, handler); + }); + videoEventListenersRef.current = null; + } + + // 暂停时立即保存 + const pauseHandler = () => { + saveCurrentPlayProgress(); + }; + + // timeupdate 节流(5 秒)保存 + let lastSave = 0; + const timeUpdateHandler = () => { + const now = Date.now(); + if (now - lastSave > 5000) { + saveCurrentPlayProgress(); + lastSave = now; + } + }; + + video.addEventListener('pause', pauseHandler); + video.addEventListener('timeupdate', timeUpdateHandler); + + videoEventListenersRef.current = { + video, + listeners: [ + { event: 'pause', handler: pauseHandler }, + { event: 'timeupdate', handler: timeUpdateHandler }, + ], + }; + }; + // 播放器创建/切换逻辑,只依赖视频URL和集数索引 useEffect(() => { if ( @@ -189,6 +240,7 @@ export default function PlayPage() { setError('视频地址无效'); return; } + console.log(videoUrl); // 检测是否为WebKit浏览器 const isWebkit = @@ -202,7 +254,11 @@ export default function PlayPage() { currentEpisodeIndex + 1 }集`; artPlayerRef.current.poster = videoCover; - console.log(videoUrl); + if (artPlayerRef.current?.video) { + attachVideoEventListeners( + artPlayerRef.current.video as HTMLVideoElement + ); + } return; } @@ -380,11 +436,41 @@ export default function PlayPage() { }, 1000); } }); + if (artPlayerRef.current?.video) { + attachVideoEventListeners( + artPlayerRef.current.video as HTMLVideoElement + ); + } } catch (err) { console.error('创建播放器失败:', err); setError('播放器初始化失败'); } - }, [videoUrl]); + }, [Artplayer, Hls, videoUrl]); + + // 页面卸载和隐藏时保存播放进度 + useEffect(() => { + // 页面即将卸载时保存播放进度 + const handleBeforeUnload = () => { + saveCurrentPlayProgress(); + }; + + // 页面可见性变化时保存播放进度 + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + saveCurrentPlayProgress(); + } + }; + + // 添加事件监听器 + window.addEventListener('beforeunload', handleBeforeUnload); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + // 清理事件监听器 + window.removeEventListener('beforeunload', handleBeforeUnload); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [currentEpisodeIndex, detail, artPlayerRef.current]); // 清理定时器 useEffect(() => { @@ -395,6 +481,18 @@ export default function PlayPage() { if (shortcutHintTimeoutRef.current) { clearTimeout(shortcutHintTimeoutRef.current); } + if (saveIntervalRef.current) { + clearInterval(saveIntervalRef.current); + } + + // 清理视频事件监听器 + if (videoEventListenersRef.current) { + const { video, listeners } = videoEventListenersRef.current; + listeners.forEach(({ event, handler }) => { + video.removeEventListener(event, handler); + }); + videoEventListenersRef.current = null; + } }; }, []); @@ -418,6 +516,14 @@ export default function PlayPage() { // 处理选集切换 const handleEpisodeChange = (episodeIndex: number) => { if (episodeIndex >= 0 && episodeIndex < totalEpisodes) { + // 在更换集数前保存当前播放进度 + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + saveCurrentPlayProgress(); + } setCurrentEpisodeIndex(episodeIndex); setShowEpisodePanel(false); } @@ -430,6 +536,14 @@ export default function PlayPage() { detail.episodes && currentEpisodeIndex < detail.episodes.length - 1 ) { + // 在更换集数前保存当前播放进度 + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + saveCurrentPlayProgress(); + } setCurrentEpisodeIndex(currentEpisodeIndex + 1); } }; @@ -478,6 +592,13 @@ export default function PlayPage() { // 处理上一集 const handlePreviousEpisode = () => { if (detail && currentEpisodeIndex > 0) { + if ( + artPlayerRef.current && + artPlayerRef.current.video && + !artPlayerRef.current.video.paused + ) { + saveCurrentPlayProgress(); + } setCurrentEpisodeIndex(currentEpisodeIndex - 1); } }; @@ -539,13 +660,23 @@ export default function PlayPage() { } }; - // 处理换源 - 使用 startTransition 批量更新状态 + // 处理换源 const handleSourceChange = async (newSource: string, newId: string) => { try { // 显示换源加载状态 setSourceChanging(true); setError(null); + // 清除前一个历史记录 + if (currentSource && currentId) { + try { + await deletePlayRecord(currentSource, currentId); + console.log('已清除前一个播放记录'); + } catch (err) { + console.error('清除播放记录失败:', err); + } + } + // 获取新源的详情 const response = await fetch( `/api/detail?source=${newSource}&id=${newId}` @@ -715,6 +846,50 @@ export default function PlayPage() { } }; + // 保存播放进度的函数 + const saveCurrentPlayProgress = async () => { + if ( + !artPlayerRef.current?.video || + !currentSource || + !currentId || + !videoTitle || + !detail?.videoInfo?.source_name + ) { + return; + } + + const video = artPlayerRef.current.video; + const currentTime = video.currentTime || 0; + const duration = video.duration || 0; + + // 如果播放时间太短(少于5秒)或者视频时长无效,不保存 + if (currentTime < 1 || !duration) { + return; + } + + try { + await savePlayRecord(currentSource, currentId, { + title: videoTitle, + source_name: detail.videoInfo.source_name, + cover: videoCover, + index: currentEpisodeIndex + 1, // 转换为1基索引 + total_episodes: totalEpisodes, + play_time: Math.floor(currentTime), + total_time: Math.floor(duration), + save_time: Date.now(), + }); + + lastSaveTimeRef.current = Date.now(); + console.log('播放进度已保存:', { + title: videoTitle, + episode: currentEpisodeIndex + 1, + progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`, + }); + } catch (err) { + console.error('保存播放进度失败:', err); + } + }; + if (loading) { return (
diff --git a/src/components/ContinueWatching.tsx b/src/components/ContinueWatching.tsx new file mode 100644 index 0000000..09f4e85 --- /dev/null +++ b/src/components/ContinueWatching.tsx @@ -0,0 +1,110 @@ +/* eslint-disable no-console */ +'use client'; + +import { useEffect, useState } from 'react'; + +import type { PlayRecord } from '@/lib/db.client'; +import { getAllPlayRecords } from '@/lib/db.client'; + +import ScrollableRow from '@/components/ScrollableRow'; +import VideoCard from '@/components/VideoCard'; + +interface ContinueWatchingProps { + className?: string; +} + +export default function ContinueWatching({ className }: ContinueWatchingProps) { + const [playRecords, setPlayRecords] = useState< + (PlayRecord & { key: string })[] + >([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchPlayRecords = async () => { + try { + setLoading(true); + + // 从 localStorage 获取所有播放记录 + const allRecords = await getAllPlayRecords(); + + // 将记录转换为数组并根据 save_time 由近到远排序 + const recordsArray = Object.entries(allRecords).map( + ([key, record]) => ({ + ...record, + key, + }) + ); + + // 按 save_time 降序排序(最新的在前面) + const sortedRecords = recordsArray.sort( + (a, b) => b.save_time - a.save_time + ); + + setPlayRecords(sortedRecords); + } catch (error) { + console.error('获取播放记录失败:', error); + setPlayRecords([]); + } finally { + setLoading(false); + } + }; + + fetchPlayRecords(); + }, []); + + // 如果没有播放记录,则不渲染组件 + if (!loading && playRecords.length === 0) { + return null; + } + + // 计算播放进度百分比 + const getProgress = (record: PlayRecord) => { + if (record.total_time === 0) return 0; + return (record.play_time / record.total_time) * 100; + }; + + // 从 key 中解析 source 和 id + const parseKey = (key: string) => { + const [source, id] = key.split('+'); + return { source, id }; + }; + + return ( +
+

+ 继续观看 +

+ + {loading + ? // 加载状态显示灰色占位数据 + Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+
+ )) + : // 显示真实数据 + playRecords.map((record) => { + const { source, id } = parseKey(record.key); + return ( +
+ +
+ ); + })} +
+
+ ); +} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 2a7ef3b..86d6b4d 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -13,6 +13,7 @@ interface VideoCardProps { source_name: string; progress?: number; from?: string; + currentEpisode?: number; } function CheckCircleCustom() { @@ -76,6 +77,7 @@ export default function VideoCard({ source_name, progress, from, + currentEpisode, }: VideoCardProps) { const [playHover, setPlayHover] = useState(false); const router = useRouter(); @@ -130,6 +132,15 @@ export default function VideoCard({ />
)} + + {/* 当前播放集数 */} + {currentEpisode && ( +
+ + {currentEpisode} + +
+ )} {/* 信息层 */} diff --git a/src/lib/__tests__/og.test.ts b/src/lib/__tests__/og.test.ts deleted file mode 100644 index 7f53f94..0000000 --- a/src/lib/__tests__/og.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/src/lib/config.ts b/src/lib/config.ts index 417faa8..bd219a8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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' }; +} diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts new file mode 100644 index 0000000..821e793 --- /dev/null +++ b/src/lib/db.client.ts @@ -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(path: string): Promise { + 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> { + // 若配置标明使用数据库,则从后端 API 拉取 + if (STORAGE_TYPE === 'database') { + return fetchFromApi>('/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; + } catch (err) { + console.error('读取播放记录失败:', err); + return {}; + } +} + +/** + * 保存播放记录到 localStorage 或通过 API 保存到数据库 + */ +export async function savePlayRecord( + source: string, + id: string, + record: Omit +): Promise { + 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 { + 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; + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..dcf69bb --- /dev/null +++ b/src/lib/db.ts @@ -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; + setPlayRecord(key: string, record: PlayRecord): Promise; + getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }>; + deletePlayRecord(key: string): Promise; + + // 收藏相关 + getFavorite(key: string): Promise; + setFavorite(key: string, favorite: Favorite): Promise; + getAllFavorites(): Promise<{ [key: string]: Favorite }>; + deleteFavorite(key: string): Promise; +} + +// 数据库实现(保留接口,待实现) +class DatabaseStorage implements IStorage { + async getPlayRecord(_key: string): Promise { + // TODO: 实现数据库查询逻辑 + throw new Error('Database storage not implemented yet'); + } + + async setPlayRecord(_key: string, _record: PlayRecord): Promise { + // 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 { + // TODO: 实现数据库删除逻辑 + throw new Error('Database storage not implemented yet'); + } + + async getFavorite(_: string): Promise { + // TODO: 实现数据库查询逻辑 + throw new Error('Database storage not implemented yet'); + } + + async setFavorite(_key: string, _favorite: Favorite): Promise { + // 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 { + // 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 { + const key = generateStorageKey(source, id); + return this.storage.getPlayRecord(key); + } + + async savePlayRecord( + source: string, + id: string, + record: Omit + ): Promise { + 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 { + const key = generateStorageKey(source, id); + await this.storage.deletePlayRecord(key); + } + + // 收藏相关方法 + async getFavorite(source: string, id: string): Promise { + const key = generateStorageKey(source, id); + return this.storage.getFavorite(key); + } + + async saveFavorite( + source: string, + id: string, + favorite: Omit + ): Promise { + 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 { + const key = generateStorageKey(source, id); + await this.storage.deleteFavorite(key); + } + + async isFavorited(source: string, id: string): Promise { + const favorite = await this.getFavorite(source, id); + return favorite !== null; + } + + async toggleFavorite( + source: string, + id: string, + favoriteData?: Omit + ): Promise { + 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(); diff --git a/src/lib/env.ts b/src/lib/env.ts index 9f1a3e3..2b8116f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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);