8 Commits

Author SHA1 Message Date
shinya cfea92ec7f fix: auth 2025-07-14 22:33:45 +08:00
shinya b61430856a feat: d1 local cache 2025-07-14 22:09:53 +08:00
shinya 7e6f4bcadc feat: update readme 2025-07-14 21:02:59 +08:00
shinya c2ebf5758e fix: add await 2025-07-14 20:53:40 +08:00
senshinya 446bb0e9f0 Update layout.tsx 2025-07-14 19:59:19 +08:00
shinya ea3d1065e8 feat: lower d1 saveCurrentPlayProgress frequency 2025-07-14 13:23:46 +08:00
shinya eb3bffab4e fix: sitename 2025-07-14 13:21:37 +08:00
senshinya 45dfdc62f0 Merge pull request #147 from senshinya/d1
support D1 storage
2025-07-14 13:15:39 +08:00
15 changed files with 1098 additions and 210 deletions
+63 -63
View File
@@ -1,75 +1,75 @@
```sql ```sql
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY, username TEXT PRIMARY KEY,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
CREATE TABLE IF NOT EXISTS play_records ( CREATE TABLE IF NOT EXISTS play_records (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
source_name TEXT NOT NULL, source_name TEXT NOT NULL,
cover TEXT NOT NULL, cover TEXT NOT NULL,
year TEXT NOT NULL, year TEXT NOT NULL,
index_episode INTEGER NOT NULL, index_episode INTEGER NOT NULL,
total_episodes INTEGER NOT NULL, total_episodes INTEGER NOT NULL,
play_time INTEGER NOT NULL, play_time INTEGER NOT NULL,
total_time INTEGER NOT NULL, total_time INTEGER NOT NULL,
save_time INTEGER NOT NULL, save_time INTEGER NOT NULL,
search_title TEXT, search_title TEXT,
UNIQUE(username, key) UNIQUE(username, key)
); );
CREATE TABLE IF NOT EXISTS favorites ( CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
source_name TEXT NOT NULL, source_name TEXT NOT NULL,
cover TEXT NOT NULL, cover TEXT NOT NULL,
year TEXT NOT NULL, year TEXT NOT NULL,
total_episodes INTEGER NOT NULL, total_episodes INTEGER NOT NULL,
save_time INTEGER NOT NULL, save_time INTEGER NOT NULL,
UNIQUE(username, key) UNIQUE(username, key)
); );
CREATE TABLE IF NOT EXISTS search_history ( CREATE TABLE IF NOT EXISTS search_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL,
keyword TEXT NOT NULL, keyword TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(username, keyword) UNIQUE(username, keyword)
); );
CREATE TABLE IF NOT EXISTS admin_config ( CREATE TABLE IF NOT EXISTS admin_config (
id INTEGER PRIMARY KEY DEFAULT 1, id INTEGER PRIMARY KEY DEFAULT 1,
config TEXT NOT NULL, config TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
); );
-- 基本索引 -- 基本索引
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username); CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username); CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username); CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username);
-- 复合索引优化查询性能 -- 复合索引优化查询性能
-- 播放记录:用户名+键值的复合索引,用于快速查找特定记录 -- 播放记录:用户名+键值的复合索引,用于快速查找特定记录
CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key); CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key);
-- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询 -- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC); CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC);
-- 收藏:用户名+键值的复合索引,用于快速查找特定收藏 -- 收藏:用户名+键值的复合索引,用于快速查找特定收藏
CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key); CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key);
-- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询 -- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC); CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC);
-- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录 -- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录
CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword); CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword);
-- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询 -- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询
CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC); CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC);
-- 搜索历史清理查询的优化索引 -- 搜索历史清理查询的优化索引
CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC);
``` ```
+13 -3
View File
@@ -23,10 +23,10 @@
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。 - 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。 - 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。 - ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
- ❤️ **收藏 + 继续观看**Docker 部署支持 Redis 存储,多端同步进度。 - ❤️ **收藏 + 继续观看**:支持 Redis/D1 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 - 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 - 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel。 - 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel 和 Cloudflare
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性) - 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
<details> <details>
@@ -78,6 +78,8 @@
### Cloudflare 部署 ### Cloudflare 部署
#### 普通部署(localstorage
1. **Fork** 本仓库到你的 GitHub 账户。 1. **Fork** 本仓库到你的 GitHub 账户。
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers-> Workers 和 Pages**,点击创建 2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers-> Workers 和 Pages**,点击创建
3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库 3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库
@@ -87,6 +89,14 @@
7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。 7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
8. 每次 Push 到 `main` 分支将自动触发重新构建。 8. 每次 Push 到 `main` 分支将自动触发重新构建。
#### D1 支持
1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意
2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 Run All,等待运行完成
3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 d1;设置 USERNAME 和 PASSWORD 作为站长账号
5. 重试部署
### Docker 部署 ### Docker 部署
> 适用于自建服务器 / NAS / 群晖等场景。 > 适用于自建服务器 / NAS / 群晖等场景。
@@ -213,7 +223,7 @@ MoonTV 支持标准的苹果 CMS V10 API 格式。
## 管理员配置 ## 管理员配置
**该特性目前仅支持通过 Docker Redis 的部署方式使用** **该特性目前仅支持通过 Docker+Redis 或 Cloudflare+D1 的部署方式使用**
支持在运行时动态变更服务配置 支持在运行时动态变更服务配置
+1 -1
View File
@@ -27,7 +27,7 @@ export async function GET(request: Request) {
} }
const result = await getDetailFromApi(apiSite, id); const result = await getDetailFromApi(apiSite, id);
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(result, { return NextResponse.json(result, {
headers: { headers: {
+2 -2
View File
@@ -108,7 +108,7 @@ export async function GET(request: Request) {
list: list, list: list,
}; };
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(response, { return NextResponse.json(response, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}`,
@@ -180,7 +180,7 @@ function handleTop250(pageStart: number) {
list: movies, list: movies,
}; };
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(apiResponse, { return NextResponse.json(apiResponse, {
headers: { headers: {
'Cache-Control': `public, max-age=${cacheTime}`, 'Cache-Control': `public, max-age=${cacheTime}`,
+2 -2
View File
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
const resourceId = searchParams.get('resourceId'); const resourceId = searchParams.get('resourceId');
if (!query || !resourceId) { if (!query || !resourceId) {
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json( return NextResponse.json(
{ result: null, error: '缺少必要参数: q 或 resourceId' }, { result: null, error: '缺少必要参数: q 或 resourceId' },
{ {
@@ -40,7 +40,7 @@ export async function GET(request: Request) {
const results = await searchFromApi(targetSite, query); const results = await searchFromApi(targetSite, query);
const result = results.filter((r) => r.title === query); const result = results.filter((r) => r.title === query);
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
if (result.length === 0) { if (result.length === 0) {
return NextResponse.json( return NextResponse.json(
+2 -2
View File
@@ -7,8 +7,8 @@ export const runtime = 'edge';
// OrionTV 兼容接口 // OrionTV 兼容接口
export async function GET() { export async function GET() {
try { try {
const apiSites = getAvailableApiSites(); const apiSites = await getAvailableApiSites();
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json(apiSites, { return NextResponse.json(apiSites, {
headers: { headers: {
+1 -1
View File
@@ -27,7 +27,7 @@ export async function GET(request: Request) {
try { try {
const results = await Promise.all(searchPromises); const results = await Promise.all(searchPromises);
const flattenedResults = results.flat(); const flattenedResults = results.flat();
const cacheTime = getCacheTime(); const cacheTime = await getCacheTime();
return NextResponse.json( return NextResponse.json(
{ results: flattenedResults }, { results: flattenedResults },
+2 -2
View File
@@ -13,7 +13,7 @@ const inter = Inter({ subsets: ['latin'] });
// 动态生成 metadata,支持配置更新后的标题变化 // 动态生成 metadata,支持配置更新后的标题变化
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
let siteName = process.env.NEXT_PUBLIC_SITE_NAME; let siteName = process.env.SITE_NAME || 'MoonTV';
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') { if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
const config = await getConfig(); const config = await getConfig();
siteName = config.SiteConfig.SiteName; siteName = config.SiteConfig.SiteName;
@@ -35,7 +35,7 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; let siteName = process.env.SITE_NAME || 'MoonTV';
let announcement = let announcement =
process.env.ANNOUNCEMENT || process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。'; '本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
+47 -29
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
'use client'; 'use client';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
@@ -9,6 +11,7 @@ import {
clearAllFavorites, clearAllFavorites,
getAllFavorites, getAllFavorites,
getAllPlayRecords, getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { DoubanItem, DoubanResult } from '@/lib/types'; import { DoubanItem, DoubanResult } from '@/lib/types';
@@ -82,42 +85,57 @@ function HomeClient() {
fetchDoubanData(); fetchDoubanData();
}, []); }, []);
// 处理收藏数据更新的函数
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
const allPlayRecords = await getAllPlayRecords();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数
const playRecord = allPlayRecords[key];
const currentEpisode = playRecord?.index;
return {
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
} as FavoriteItem;
});
setFavoriteItems(sorted);
};
// 当切换到收藏夹时加载收藏数据 // 当切换到收藏夹时加载收藏数据
useEffect(() => { useEffect(() => {
if (activeTab !== 'favorites') return; if (activeTab !== 'favorites') return;
(async () => { const loadFavorites = async () => {
const [allFavorites, allPlayRecords] = await Promise.all([ const allFavorites = await getAllFavorites();
getAllFavorites(), await updateFavoriteItems(allFavorites);
getAllPlayRecords(), };
]);
// 根据保存时间排序(从近到远) loadFavorites();
const sorted = Object.entries(allFavorites)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
// 查找对应的播放记录,获取当前集数 // 监听收藏更新事件
const playRecord = allPlayRecords[key]; const unsubscribe = subscribeToDataUpdates(
const currentEpisode = playRecord?.index; 'favoritesUpdated',
(newFavorites: Record<string, any>) => {
updateFavoriteItems(newFavorites);
}
);
return { return unsubscribe;
id,
source,
title: fav.title,
year: fav.year,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
currentEpisode,
search_title: fav?.search_title,
} as FavoriteItem;
});
setFavoriteItems(sorted);
})();
}, [activeTab]); }, [activeTab]);
const handleCloseAnnouncement = (announcement: string) => { const handleCloseAnnouncement = (announcement: string) => {
+21 -1
View File
@@ -14,6 +14,7 @@ import {
getAllPlayRecords, getAllPlayRecords,
isFavorited, isFavorited,
savePlayRecord, savePlayRecord,
subscribeToDataUpdates,
toggleFavorite, toggleFavorite,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
@@ -916,6 +917,22 @@ function PlayPageClient() {
})(); })();
}, [currentSource, currentId]); }, [currentSource, currentId]);
// 监听收藏数据更新事件
useEffect(() => {
if (!currentSource || !currentId) return;
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(favorites: Record<string, any>) => {
const key = generateStorageKey(currentSource, currentId);
const isFav = !!favorites[key];
setFavorited(isFav);
}
);
return unsubscribe;
}, [currentSource, currentId]);
// 切换收藏 // 切换收藏
const handleToggleFavorite = async () => { const handleToggleFavorite = async () => {
if ( if (
@@ -1205,7 +1222,10 @@ function PlayPageClient() {
artPlayerRef.current.on('video:timeupdate', () => { artPlayerRef.current.on('video:timeupdate', () => {
const now = Date.now(); const now = Date.now();
if (now - lastSaveTimeRef.current > 5000) { if (
now - lastSaveTimeRef.current >
(process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1' ? 10000 : 5000)
) {
saveCurrentPlayProgress(); saveCurrentPlayProgress();
lastSaveTimeRef.current = now; lastSaveTimeRef.current = now;
} }
+21 -17
View File
@@ -10,6 +10,7 @@ import {
clearSearchHistory, clearSearchHistory,
deleteSearchHistory, deleteSearchHistory,
getSearchHistory, getSearchHistory,
subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
@@ -85,7 +86,19 @@ function SearchPageClient() {
useEffect(() => { useEffect(() => {
// 无搜索参数时聚焦搜索框 // 无搜索参数时聚焦搜索框
!searchParams.get('q') && document.getElementById('searchInput')?.focus(); !searchParams.get('q') && document.getElementById('searchInput')?.focus();
// 初始加载搜索历史
getSearchHistory().then(setSearchHistory); getSearchHistory().then(setSearchHistory);
// 监听搜索历史更新事件
const unsubscribe = subscribeToDataUpdates(
'searchHistoryUpdated',
(newHistory: string[]) => {
setSearchHistory(newHistory);
}
);
return unsubscribe;
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -95,11 +108,8 @@ function SearchPageClient() {
setSearchQuery(query); setSearchQuery(query);
fetchSearchResults(query); fetchSearchResults(query);
// 保存到搜索历史 // 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(query).then(async () => { addSearchHistory(query);
const history = await getSearchHistory();
setSearchHistory(history);
});
} else { } else {
setShowResults(false); setShowResults(false);
} }
@@ -161,11 +171,8 @@ function SearchPageClient() {
// 直接发请求 // 直接发请求
fetchSearchResults(trimmed); fetchSearchResults(trimmed);
// 保存到搜索历史 // 保存到搜索历史 (事件监听会自动更新界面)
addSearchHistory(trimmed).then(async () => { addSearchHistory(trimmed);
const history = await getSearchHistory();
setSearchHistory(history);
});
}; };
return ( return (
@@ -276,9 +283,8 @@ function SearchPageClient() {
{searchHistory.length > 0 && ( {searchHistory.length > 0 && (
<button <button
onClick={async () => { onClick={() => {
await clearSearchHistory(); clearSearchHistory(); // 事件监听会自动更新界面
setSearchHistory([]);
}} }}
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500' className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
> >
@@ -303,12 +309,10 @@ function SearchPageClient() {
{/* 删除按钮 */} {/* 删除按钮 */}
<button <button
aria-label='删除搜索历史' aria-label='删除搜索历史'
onClick={async (e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
await deleteSearchHistory(item); deleteSearchHistory(item); // 事件监听会自动更新界面
const history = await getSearchHistory();
setSearchHistory(history);
}} }}
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors' className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
> >
+33 -17
View File
@@ -4,7 +4,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { PlayRecord } from '@/lib/db.client'; import type { PlayRecord } from '@/lib/db.client';
import { clearAllPlayRecords, getAllPlayRecords } from '@/lib/db.client'; import {
clearAllPlayRecords,
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import ScrollableRow from '@/components/ScrollableRow'; import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard'; import VideoCard from '@/components/VideoCard';
@@ -19,28 +23,30 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
>([]); >([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 处理播放记录数据更新的函数
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
// 将记录转换为数组并根据 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);
};
useEffect(() => { useEffect(() => {
const fetchPlayRecords = async () => { const fetchPlayRecords = async () => {
try { try {
setLoading(true); setLoading(true);
// 从 localStorage 获取所有播放记录 // 从缓存或API获取所有播放记录
const allRecords = await getAllPlayRecords(); const allRecords = await getAllPlayRecords();
updatePlayRecords(allRecords);
// 将记录转换为数组并根据 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) { } catch (error) {
console.error('获取播放记录失败:', error); console.error('获取播放记录失败:', error);
setPlayRecords([]); setPlayRecords([]);
@@ -50,6 +56,16 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
}; };
fetchPlayRecords(); fetchPlayRecords();
// 监听播放记录更新事件
const unsubscribe = subscribeToDataUpdates(
'playRecordsUpdated',
(newRecords: Record<string, PlayRecord>) => {
updatePlayRecords(newRecords);
}
);
return unsubscribe;
}, []); }, []);
// 如果没有播放记录,则不渲染组件 // 如果没有播放记录,则不渲染组件
+24 -1
View File
@@ -1,9 +1,17 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react'; import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client'; import {
deletePlayRecord,
generateStorageKey,
isFavorited,
subscribeToDataUpdates,
toggleFavorite,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
import { ImagePlaceholder } from '@/components/ImagePlaceholder'; import { ImagePlaceholder } from '@/components/ImagePlaceholder';
@@ -103,6 +111,7 @@ export default function VideoCard({
// 获取收藏状态 // 获取收藏状态
useEffect(() => { useEffect(() => {
if (from === 'douban' || !actualSource || !actualId) return; if (from === 'douban' || !actualSource || !actualId) return;
const fetchFavoriteStatus = async () => { const fetchFavoriteStatus = async () => {
try { try {
const fav = await isFavorited(actualSource, actualId); const fav = await isFavorited(actualSource, actualId);
@@ -111,7 +120,21 @@ export default function VideoCard({
throw new Error('检查收藏状态失败'); throw new Error('检查收藏状态失败');
} }
}; };
fetchFavoriteStatus(); fetchFavoriteStatus();
// 监听收藏状态更新事件
const storageKey = generateStorageKey(actualSource, actualId);
const unsubscribe = subscribeToDataUpdates(
'favoritesUpdated',
(newFavorites: Record<string, any>) => {
// 检查当前项目是否在新的收藏列表中
const isNowFavorited = !!newFavorites[storageKey];
setFavorited(isNowFavorited);
}
);
return unsubscribe;
}, [from, actualSource, actualId]); }, [from, actualSource, actualId]);
const handleToggleFavorite = useCallback( const handleToggleFavorite = useCallback(
+850 -57
View File
@@ -1,4 +1,4 @@
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */ /* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
'use client'; 'use client';
/** /**
@@ -9,10 +9,13 @@
* 功能: * 功能:
* 1. 获取全部播放记录(getAllPlayRecords)。 * 1. 获取全部播放记录(getAllPlayRecords)。
* 2. 保存播放记录(savePlayRecord)。 * 2. 保存播放记录(savePlayRecord)。
* 3. D1 存储模式下的混合缓存策略,提升用户体验。
* *
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。 * 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
*/ */
import { getAuthInfoFromBrowserCookie } from './auth';
// ---- 类型 ---- // ---- 类型 ----
export interface PlayRecord { export interface PlayRecord {
title: string; title: string;
@@ -27,8 +30,39 @@ export interface PlayRecord {
search_title?: string; // 搜索时使用的标题 search_title?: string; // 搜索时使用的标题
} }
// ---- 收藏类型 ----
export interface Favorite {
title: string;
source_name: string;
year: string;
cover: string;
total_episodes: number;
save_time: number;
search_title?: string;
}
// ---- 缓存数据结构 ----
interface CacheData<T> {
data: T;
timestamp: number;
version: string;
}
interface UserCacheStore {
playRecords?: CacheData<Record<string, PlayRecord>>;
favorites?: CacheData<Record<string, Favorite>>;
searchHistory?: CacheData<string[]>;
}
// ---- 常量 ---- // ---- 常量 ----
const PLAY_RECORDS_KEY = 'moontv_play_records'; const PLAY_RECORDS_KEY = 'moontv_play_records';
const FAVORITES_KEY = 'moontv_favorites';
const SEARCH_HISTORY_KEY = 'moontv_search_history';
// 缓存相关常量
const CACHE_PREFIX = 'moontv_cache_';
const CACHE_VERSION = '1.0.0';
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
// ---- 环境变量 ---- // ---- 环境变量 ----
const STORAGE_TYPE = (() => { const STORAGE_TYPE = (() => {
@@ -37,16 +71,288 @@ const STORAGE_TYPE = (() => {
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) || (window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
(process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) || (process.env.STORAGE_TYPE as 'localstorage' | 'redis' | 'd1' | undefined) ||
'localstorage'; 'localstorage';
// 兼容 redis => database
return raw; return raw;
})(); })();
// ---------------- 搜索历史相关常量 ---------------- // ---------------- 搜索历史相关常量 ----------------
const SEARCH_HISTORY_KEY = 'moontv_search_history';
// 搜索历史最大保存条数 // 搜索历史最大保存条数
const SEARCH_HISTORY_LIMIT = 20; const SEARCH_HISTORY_LIMIT = 20;
// ---- 缓存管理器 ----
class HybridCacheManager {
private static instance: HybridCacheManager;
static getInstance(): HybridCacheManager {
if (!HybridCacheManager.instance) {
HybridCacheManager.instance = new HybridCacheManager();
}
return HybridCacheManager.instance;
}
/**
* 获取当前用户名
*/
private getCurrentUsername(): string | null {
const authInfo = getAuthInfoFromBrowserCookie();
return authInfo?.username || null;
}
/**
* 生成用户专属的缓存key
*/
private getUserCacheKey(username: string): string {
return `${CACHE_PREFIX}${username}`;
}
/**
* 获取用户缓存数据
*/
private getUserCache(username: string): UserCacheStore {
if (typeof window === 'undefined') return {};
try {
const cacheKey = this.getUserCacheKey(username);
const cached = localStorage.getItem(cacheKey);
return cached ? JSON.parse(cached) : {};
} catch (error) {
console.warn('获取用户缓存失败:', error);
return {};
}
}
/**
* 保存用户缓存数据
*/
private saveUserCache(username: string, cache: UserCacheStore): void {
if (typeof window === 'undefined') return;
try {
const cacheKey = this.getUserCacheKey(username);
localStorage.setItem(cacheKey, JSON.stringify(cache));
} catch (error) {
console.warn('保存用户缓存失败:', error);
}
}
/**
* 检查缓存是否有效
*/
private isCacheValid<T>(cache: CacheData<T>): boolean {
const now = Date.now();
return (
cache.version === CACHE_VERSION &&
now - cache.timestamp < CACHE_EXPIRE_TIME
);
}
/**
* 创建缓存数据
*/
private createCacheData<T>(data: T): CacheData<T> {
return {
data,
timestamp: Date.now(),
version: CACHE_VERSION,
};
}
/**
* 获取缓存的播放记录
*/
getCachedPlayRecords(): Record<string, PlayRecord> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.playRecords;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存播放记录
*/
cachePlayRecords(data: Record<string, PlayRecord>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.playRecords = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的收藏
*/
getCachedFavorites(): Record<string, Favorite> | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.favorites;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存收藏
*/
cacheFavorites(data: Record<string, Favorite>): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.favorites = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 获取缓存的搜索历史
*/
getCachedSearchHistory(): string[] | null {
const username = this.getCurrentUsername();
if (!username) return null;
const userCache = this.getUserCache(username);
const cached = userCache.searchHistory;
if (cached && this.isCacheValid(cached)) {
return cached.data;
}
return null;
}
/**
* 缓存搜索历史
*/
cacheSearchHistory(data: string[]): void {
const username = this.getCurrentUsername();
if (!username) return;
const userCache = this.getUserCache(username);
userCache.searchHistory = this.createCacheData(data);
this.saveUserCache(username, userCache);
}
/**
* 清除指定用户的所有缓存
*/
clearUserCache(username?: string): void {
const targetUsername = username || this.getCurrentUsername();
if (!targetUsername) return;
try {
const cacheKey = this.getUserCacheKey(targetUsername);
localStorage.removeItem(cacheKey);
} catch (error) {
console.warn('清除用户缓存失败:', error);
}
}
/**
* 清除所有过期缓存
*/
clearExpiredCaches(): void {
if (typeof window === 'undefined') return;
try {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
try {
const cache = JSON.parse(localStorage.getItem(key) || '{}');
// 检查是否有任何缓存数据过期
let hasValidData = false;
for (const [, cacheData] of Object.entries(cache)) {
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
hasValidData = true;
break;
}
}
if (!hasValidData) {
keysToRemove.push(key);
}
} catch {
// 解析失败的缓存也删除
keysToRemove.push(key);
}
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch (error) {
console.warn('清除过期缓存失败:', error);
}
}
}
// 获取缓存管理器实例
const cacheManager = HybridCacheManager.getInstance();
// ---- 错误处理辅助函数 ----
/**
* 数据库操作失败时的通用错误处理
* 立即从数据库刷新对应类型的缓存以保持数据一致性
*/
async function handleDatabaseOperationFailure(
dataType: 'playRecords' | 'favorites' | 'searchHistory',
error: any
): Promise<void> {
console.error(`数据库操作失败 (${dataType}):`, error);
try {
let freshData: any;
let eventName: string;
switch (dataType) {
case 'playRecords':
freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
eventName = 'playRecordsUpdated';
break;
case 'favorites':
freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
eventName = 'favoritesUpdated';
break;
case 'searchHistory':
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
eventName = 'searchHistoryUpdated';
break;
}
// 触发更新事件通知组件
window.dispatchEvent(
new CustomEvent(eventName, {
detail: freshData,
})
);
} catch (refreshErr) {
console.error(`刷新${dataType}缓存失败:`, refreshErr);
}
}
// 页面加载时清理过期缓存
if (typeof window !== 'undefined') {
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
}
// ---- 工具函数 ---- // ---- 工具函数 ----
async function fetchFromApi<T>(path: string): Promise<T> { async function fetchFromApi<T>(path: string): Promise<T> {
const res = await fetch(path); const res = await fetch(path);
@@ -63,21 +369,62 @@ export function generateStorageKey(source: string, id: string): string {
// ---- API ---- // ---- API ----
/** /**
* 读取 localStorage 中的全部播放记录。 * 读取全部播放记录。
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。 * 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
*/ */
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> { export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
// 若配置标明使用数据库,则从后端 API 拉取 // 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
if (typeof window === 'undefined') {
return {};
}
// D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedPlayRecords();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cachePlayRecords(freshData);
// 触发数据更新事件,供组件监听
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步播放记录失败:', err);
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
`/api/playrecords`
);
cacheManager.cachePlayRecords(freshData);
return freshData;
} catch (err) {
console.error('获取播放记录失败:', err);
return {};
}
}
}
// 其他数据库存储模式:直接从 API 获取
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
return fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`); return fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`);
} }
// 默认 / localstorage 流程 // localstorage 模式
if (typeof window === 'undefined') {
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
return {};
}
try { try {
const raw = localStorage.getItem(PLAY_RECORDS_KEY); const raw = localStorage.getItem(PLAY_RECORDS_KEY);
if (!raw) return {}; if (!raw) return {};
@@ -89,7 +436,8 @@ export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
} }
/** /**
* 保存播放记录到 localStorage 或通过 API 保存到数据库 * 保存播放记录
* D1 存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
*/ */
export async function savePlayRecord( export async function savePlayRecord(
source: string, source: string,
@@ -98,7 +446,41 @@ export async function savePlayRecord(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 若配置标明使用数据库,则通过 API 保存 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
cachedRecords[key] = record;
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
const res = await fetch('/api/playrecords', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, record }),
});
if (!res.ok) {
throw new Error(`保存播放记录失败: ${res.status}`);
}
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
throw err;
}
return;
}
// 其他数据库存储模式:直接通过 API 保存
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
const res = await fetch('/api/playrecords', { const res = await fetch('/api/playrecords', {
@@ -116,7 +498,7 @@ export async function savePlayRecord(
return; return;
} }
// 默认 / localstorage 流程 // localstorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端保存播放记录到 localStorage'); console.warn('无法在服务端保存播放记录到 localStorage');
return; return;
@@ -133,7 +515,8 @@ export async function savePlayRecord(
} }
/** /**
* 删除播放记录 * 删除播放记录
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function deletePlayRecord( export async function deletePlayRecord(
source: string, source: string,
@@ -141,7 +524,37 @@ export async function deletePlayRecord(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 若配置标明使用数据库,则通过 API 删除 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
delete cachedRecords[key];
cacheManager.cachePlayRecords(cachedRecords);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: cachedRecords,
})
);
// 异步同步到数据库
try {
const res = await fetch(
`/api/playrecords?key=${encodeURIComponent(key)}`,
{
method: 'DELETE',
}
);
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
} catch (err) {
await handleDatabaseOperationFailure('playRecords', err);
throw err;
}
return;
}
// 其他数据库存储模式:直接通过 API 删除
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
const res = await fetch( const res = await fetch(
@@ -158,7 +571,7 @@ export async function deletePlayRecord(
return; return;
} }
// 默认 / localstorage 流程 // localstorage 模式
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
console.warn('无法在服务端删除播放记录到 localStorage'); console.warn('无法在服务端删除播放记录到 localStorage');
return; return;
@@ -178,10 +591,54 @@ export async function deletePlayRecord(
/* ---------------- 搜索历史相关 API ---------------- */ /* ---------------- 搜索历史相关 API ---------------- */
/** /**
* 获取搜索历史 * 获取搜索历史
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function getSearchHistory(): Promise<string[]> { export async function getSearchHistory(): Promise<string[]> {
// 如果配置为使用数据库,则从后端 API 获取 // 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return [];
}
// D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedSearchHistory();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<string[]>(`/api/searchhistory`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheSearchHistory(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步搜索历史失败:', err);
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
cacheManager.cacheSearchHistory(freshData);
return freshData;
} catch (err) {
console.error('获取搜索历史失败:', err);
return [];
}
}
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
return fetchFromApi<string[]>(`/api/searchhistory`); return fetchFromApi<string[]>(`/api/searchhistory`);
@@ -191,11 +648,7 @@ export async function getSearchHistory(): Promise<string[]> {
} }
} }
// 默认从 localStorage 读取 // localStorage 模式
if (typeof window === 'undefined') {
return [];
}
try { try {
const raw = localStorage.getItem(SEARCH_HISTORY_KEY); const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
if (!raw) return []; if (!raw) return [];
@@ -209,13 +662,48 @@ export async function getSearchHistory(): Promise<string[]> {
} }
/** /**
* 将关键字添加到搜索历史 * 将关键字添加到搜索历史
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function addSearchHistory(keyword: string): Promise<void> { export async function addSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim(); const trimmed = keyword.trim();
if (!trimmed) return; if (!trimmed) return;
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
// 限制长度
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
}
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try {
const res = await fetch('/api/searchhistory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ keyword: trimmed }),
});
if (!res.ok) throw new Error(`保存搜索历史失败: ${res.status}`);
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
await fetch('/api/searchhistory', { await fetch('/api/searchhistory', {
@@ -248,10 +736,35 @@ export async function addSearchHistory(keyword: string): Promise<void> {
} }
/** /**
* 清空搜索历史 * 清空搜索历史
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function clearSearchHistory(): Promise<void> { export async function clearSearchHistory(): Promise<void> {
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
cacheManager.cacheSearchHistory([]);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: [],
})
);
// 异步同步到数据库
try {
const res = await fetch(`/api/searchhistory`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`清空搜索历史失败: ${res.status}`);
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
await fetch(`/api/searchhistory`, { await fetch(`/api/searchhistory`, {
@@ -269,13 +782,43 @@ export async function clearSearchHistory(): Promise<void> {
} }
/** /**
* 删除单条搜索历史 * 删除单条搜索历史
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function deleteSearchHistory(keyword: string): Promise<void> { export async function deleteSearchHistory(keyword: string): Promise<void> {
const trimmed = keyword.trim(); const trimmed = keyword.trim();
if (!trimmed) return; if (!trimmed) return;
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
const newHistory = cachedHistory.filter((k) => k !== trimmed);
cacheManager.cacheSearchHistory(newHistory);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: newHistory,
})
);
// 异步同步到数据库
try {
const res = await fetch(
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
{
method: 'DELETE',
}
);
if (!res.ok) throw new Error(`删除搜索历史失败: ${res.status}`);
} catch (err) {
await handleDatabaseOperationFailure('searchHistory', err);
}
return;
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, { await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, {
@@ -301,34 +844,62 @@ export async function deleteSearchHistory(keyword: string): Promise<void> {
// ---------------- 收藏相关 API ---------------- // ---------------- 收藏相关 API ----------------
// 收藏数据结构
export interface Favorite {
title: string;
source_name: string;
year: string;
cover: string;
total_episodes: number;
save_time: number;
search_title?: string; // 搜索时使用的标题
}
// 收藏在 localStorage 中使用的 key
const FAVORITES_KEY = 'moontv_favorites';
/** /**
* 获取全部收藏 * 获取全部收藏
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
*/ */
export async function getAllFavorites(): Promise<Record<string, Favorite>> { export async function getAllFavorites(): Promise<Record<string, Favorite>> {
// 数据库模式 // 服务器端渲染阶段直接返回空
if (typeof window === 'undefined') {
return {};
}
// D1 存储模式:使用混合缓存策略
if (STORAGE_TYPE === 'd1') {
// 优先从缓存获取数据
const cachedData = cacheManager.getCachedFavorites();
if (cachedData) {
// 返回缓存数据,同时后台异步更新
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
.then((freshData) => {
// 只有数据真正不同时才更新缓存
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
cacheManager.cacheFavorites(freshData);
// 触发数据更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: freshData,
})
);
}
})
.catch((err) => {
console.warn('后台同步收藏失败:', err);
});
return cachedData;
} else {
// 缓存为空,直接从 API 获取并缓存
try {
const freshData = await fetchFromApi<Record<string, Favorite>>(
`/api/favorites`
);
cacheManager.cacheFavorites(freshData);
return freshData;
} catch (err) {
console.error('获取收藏失败:', err);
return {};
}
}
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
return fetchFromApi<Record<string, Favorite>>(`/api/favorites`); return fetchFromApi<Record<string, Favorite>>(`/api/favorites`);
} }
// localStorage 模式 // localStorage 模式
if (typeof window === 'undefined') {
return {};
}
try { try {
const raw = localStorage.getItem(FAVORITES_KEY); const raw = localStorage.getItem(FAVORITES_KEY);
if (!raw) return {}; if (!raw) return {};
@@ -340,7 +911,8 @@ export async function getAllFavorites(): Promise<Record<string, Favorite>> {
} }
/** /**
* 保存收藏 * 保存收藏
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function saveFavorite( export async function saveFavorite(
source: string, source: string,
@@ -349,7 +921,38 @@ export async function saveFavorite(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
cachedFavorites[key] = favorite;
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try {
const res = await fetch('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key, favorite }),
});
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
throw err;
}
return;
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
const res = await fetch('/api/favorites', { const res = await fetch('/api/favorites', {
@@ -384,7 +987,8 @@ export async function saveFavorite(
} }
/** /**
* 删除收藏 * 删除收藏
* D1 存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
*/ */
export async function deleteFavorite( export async function deleteFavorite(
source: string, source: string,
@@ -392,7 +996,34 @@ export async function deleteFavorite(
): Promise<void> { ): Promise<void> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 数据库模式 // D1 存储模式:乐观更新策略
if (STORAGE_TYPE === 'd1') {
// 立即更新缓存
const cachedFavorites = cacheManager.getCachedFavorites() || {};
delete cachedFavorites[key];
cacheManager.cacheFavorites(cachedFavorites);
// 触发立即更新事件
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: cachedFavorites,
})
);
// 异步同步到数据库
try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
} catch (err) {
await handleDatabaseOperationFailure('favorites', err);
throw err;
}
return;
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, { const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
@@ -423,7 +1054,8 @@ export async function deleteFavorite(
} }
/** /**
* 判断是否已收藏 * 判断是否已收藏
* D1 存储模式下优先使用缓存数据。
*/ */
export async function isFavorited( export async function isFavorited(
source: string, source: string,
@@ -431,7 +1063,26 @@ export async function isFavorited(
): Promise<boolean> { ): Promise<boolean> {
const key = generateStorageKey(source, id); const key = generateStorageKey(source, id);
// 数据库模式 // D1 存储模式:优先使用缓存
if (STORAGE_TYPE === 'd1') {
const cachedFavorites = cacheManager.getCachedFavorites();
if (cachedFavorites) {
return !!cachedFavorites[key];
}
// 缓存为空时从 API 获取
try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
if (!res.ok) return false;
const data = await res.json();
return !!data;
} catch (err) {
console.error('检查收藏状态失败:', err);
return false;
}
}
// 其他数据库存储模式
if (STORAGE_TYPE !== 'localstorage') { if (STORAGE_TYPE !== 'localstorage') {
try { try {
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`); const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`);
@@ -516,3 +1167,145 @@ export async function clearAllFavorites(): Promise<void> {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
localStorage.removeItem(FAVORITES_KEY); localStorage.removeItem(FAVORITES_KEY);
} }
// ---------------- 混合缓存辅助函数 ----------------
/**
* 清除当前用户的所有缓存数据
* 用于用户登出时清理缓存
*/
export function clearUserCache(): void {
if (STORAGE_TYPE === 'd1') {
cacheManager.clearUserCache();
}
}
/**
* 手动刷新所有缓存数据
* 强制从服务器重新获取数据并更新缓存
*/
export async function refreshAllCache(): Promise<void> {
if (STORAGE_TYPE !== 'd1') return;
try {
// 并行刷新所有数据
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
fetchFromApi<string[]>(`/api/searchhistory`),
]);
if (playRecords.status === 'fulfilled') {
cacheManager.cachePlayRecords(playRecords.value);
window.dispatchEvent(
new CustomEvent('playRecordsUpdated', {
detail: playRecords.value,
})
);
}
if (favorites.status === 'fulfilled') {
cacheManager.cacheFavorites(favorites.value);
window.dispatchEvent(
new CustomEvent('favoritesUpdated', {
detail: favorites.value,
})
);
}
if (searchHistory.status === 'fulfilled') {
cacheManager.cacheSearchHistory(searchHistory.value);
window.dispatchEvent(
new CustomEvent('searchHistoryUpdated', {
detail: searchHistory.value,
})
);
}
} catch (err) {
console.error('刷新缓存失败:', err);
}
}
/**
* 获取缓存状态信息
* 用于调试和监控缓存健康状态
*/
export function getCacheStatus(): {
hasPlayRecords: boolean;
hasFavorites: boolean;
hasSearchHistory: boolean;
username: string | null;
} {
if (STORAGE_TYPE !== 'd1') {
return {
hasPlayRecords: false,
hasFavorites: false,
hasSearchHistory: false,
username: null,
};
}
const authInfo = getAuthInfoFromBrowserCookie();
return {
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
hasFavorites: !!cacheManager.getCachedFavorites(),
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
username: authInfo?.username || null,
};
}
// ---------------- React Hook 辅助类型 ----------------
export type CacheUpdateEvent =
| 'playRecordsUpdated'
| 'favoritesUpdated'
| 'searchHistoryUpdated';
/**
* 用于 React 组件监听数据更新的事件监听器
* 使用方法:
*
* useEffect(() => {
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
* setPlayRecords(data);
* });
* return unsubscribe;
* }, []);
*/
export function subscribeToDataUpdates<T>(
eventType: CacheUpdateEvent,
callback: (data: T) => void
): () => void {
if (typeof window === 'undefined') {
return () => {};
}
const handleUpdate = (event: CustomEvent) => {
callback(event.detail);
};
window.addEventListener(eventType, handleUpdate as EventListener);
return () => {
window.removeEventListener(eventType, handleUpdate as EventListener);
};
}
/**
* 预加载所有用户数据到缓存
* 适合在应用启动时调用,提升后续访问速度
*/
export async function preloadUserData(): Promise<void> {
if (STORAGE_TYPE !== 'd1') return;
// 检查是否已有有效缓存,避免重复请求
const status = getCacheStatus();
if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) {
return;
}
// 后台静默预加载,不阻塞界面
refreshAllCache().catch((err) => {
console.warn('预加载用户数据失败:', err);
});
}
+16 -12
View File
@@ -23,13 +23,13 @@ export async function middleware(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) { if (!authInfo) {
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
// localstorage模式:在middleware中完成验证 // localstorage模式:在middleware中完成验证
if (storageType === 'localstorage') { if (storageType === 'localstorage') {
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) { if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
return NextResponse.next(); return NextResponse.next();
} }
@@ -37,7 +37,7 @@ export async function middleware(request: NextRequest) {
// 其他模式:只验证签名 // 其他模式:只验证签名
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中) // 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
if (!authInfo.username || !authInfo.signature) { if (!authInfo.username || !authInfo.signature) {
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
// 验证签名(如果存在) // 验证签名(如果存在)
@@ -55,7 +55,7 @@ export async function middleware(request: NextRequest) {
} }
// 签名验证失败或不存在签名 // 签名验证失败或不存在签名
return redirectToLogin(request, pathname); return handleAuthFailure(request, pathname);
} }
// 验证签名 // 验证签名
@@ -96,8 +96,17 @@ async function verifySignature(
} }
} }
// 重定向到登录页面 // 处理认证失败的情况
function redirectToLogin(request: NextRequest, pathname: string): NextResponse { function handleAuthFailure(
request: NextRequest,
pathname: string
): NextResponse {
// 如果是 API 路由,返回 401 状态码
if (pathname.startsWith('/api')) {
return new NextResponse('Unauthorized', { status: 401 });
}
// 否则重定向到登录页面
const loginUrl = new URL('/login', request.url); const loginUrl = new URL('/login', request.url);
// 保留完整的URL,包括查询参数 // 保留完整的URL,包括查询参数
const fullUrl = `${pathname}${request.nextUrl.search}`; const fullUrl = `${pathname}${request.nextUrl.search}`;
@@ -108,11 +117,6 @@ function redirectToLogin(request: NextRequest, pathname: string): NextResponse {
// 判断是否需要跳过认证的路径 // 判断是否需要跳过认证的路径
function shouldSkipAuth(pathname: string): boolean { function shouldSkipAuth(pathname: string): boolean {
const skipPaths = [ const skipPaths = [
'/login',
'/api/login',
'/api/register',
'/api/logout',
'/api/server-config',
'/_next', '/_next',
'/favicon.ico', '/favicon.ico',
'/robots.txt', '/robots.txt',
@@ -128,6 +132,6 @@ function shouldSkipAuth(pathname: string): boolean {
// 配置middleware匹配规则 // 配置middleware匹配规则
export const config = { export const config = {
matcher: [ matcher: [
'/((?!_next/static|_next/image|favicon.ico|api/detail|api/search|api/image-proxy|api/douban|api/cron|api/server-config).*)', '/((?!_next/static|_next/image|favicon.ico|login|api/login|api/register|api/logout|api/cron|api/server-config).*)',
], ],
}; };