diff --git a/README.md b/README.md index f048c77..bf84877 100644 --- a/README.md +++ b/README.md @@ -102,50 +102,14 @@ docker run -d --name moontv -p 3000:3000 ghcr.io/senshinya/moontv:latest 访问 `http://服务器 IP:3000` 即可。 -#### 2. docker-compose 示例 -```yaml -version: '3.9' -services: - moontv: - image: ghcr.io/senshinya/moontv:latest - container_name: moontv - restart: unless-stopped - ports: - - '3000:3000' - environment: - - PASSWORD=your_password - # 如需自定义配置,可挂载文件 - # volumes: - # - ./config.json:/app/config.json:ro -``` - -执行: - -```bash -docker compose up -d -``` - -随后同样访问 `http://服务器 IP:3000`。 - -### **请勿使用 Pull Bot 自动同步** - -Pull Bot 会反复触发无效的 PR 和垃圾邮件,严重干扰项目维护。作者可能会直接拉黑所有 Pull Bot 自动发起的同步请求的仓库所有者。 - -**推荐做法:** - -建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。 - -如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。 - -## Compose 最佳实践 +## Docker Compose 最佳实践 若你使用 docker compose 部署,以下是一些 compose 示例 ### local storage 版本 ```yaml -version: '3.9' services: moontv: image: ghcr.io/senshinya/moontv:latest @@ -163,9 +127,8 @@ services: ### Redis 版本(推荐,多账户数据隔离,跨设备同步) ```yaml -version: '3.9' services: - moontv: + moontv-core: image: ghcr.io/senshinya/moontv:latest container_name: moontv restart: unless-stopped @@ -173,20 +136,35 @@ services: - '3000:3000' environment: - NEXT_PUBLIC_STORAGE_TYPE=redis - - REDIS_URL=redis://redis:6379 + - REDIS_URL=redis://moontv-redis:6379 - NEXT_PUBLIC_ENABLE_REGISTER=true # 首次部署请设置该变量,注册初始账户后可关闭 + networks: + - moontv-network + depends_on: + - moontv-redis # 如需自定义配置,可挂载文件 # volumes: # - ./config.json:/app/config.json:ro - redis: + moontv-redis: image: redis container_name: moontv-redis restart: unless-stopped + networks: + - moontv-network # 如需持久化 # volumes: # - ./data:/data +networks: + moontv-network: + driver: bridge ``` +## 自动同步最近更改 + +建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。 + +如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。 + ## 环境变量 | 变量 | 说明 | 可选值 | 默认值 | diff --git a/src/app/api/searchhistory/route.ts b/src/app/api/searchhistory/route.ts index 1bf7100..d1cd904 100644 --- a/src/app/api/searchhistory/route.ts +++ b/src/app/api/searchhistory/route.ts @@ -10,12 +10,22 @@ export const runtime = 'edge'; const HISTORY_LIMIT = 20; /** - * GET /api/searchhistory + * GET /api/searchhistory?user= * 返回 string[] */ -export async function GET() { +export async function GET(request: NextRequest) { try { - const history = await db.getSearchHistory(); + const { searchParams } = new URL(request.url); + const user = searchParams.get('user')?.trim(); + + if (!user) { + return NextResponse.json( + { error: 'User parameter is required' }, + { status: 400 } + ); + } + + const history = await db.getSearchHistory(user); return NextResponse.json(history, { status: 200 }); } catch (err) { console.error('获取搜索历史失败', err); @@ -28,12 +38,14 @@ export async function GET() { /** * POST /api/searchhistory - * body: { keyword: string } + * body: { keyword: string, user: string } */ export async function POST(request: NextRequest) { try { const body = await request.json(); const keyword: string = body.keyword?.trim(); + const user: string = body.user?.trim(); + if (!keyword) { return NextResponse.json( { error: 'Keyword is required' }, @@ -41,10 +53,17 @@ export async function POST(request: NextRequest) { ); } - await db.addSearchHistory(keyword); + if (!user) { + return NextResponse.json( + { error: 'User parameter is required' }, + { status: 400 } + ); + } + + await db.addSearchHistory(user, keyword); // 再次获取最新列表,确保客户端与服务端同步 - const history = await db.getSearchHistory(); + const history = await db.getSearchHistory(user); return NextResponse.json(history.slice(0, HISTORY_LIMIT), { status: 200 }); } catch (err) { console.error('添加搜索历史失败', err); @@ -56,7 +75,7 @@ export async function POST(request: NextRequest) { } /** - * DELETE /api/searchhistory + * DELETE /api/searchhistory?user=&keyword= * * 1. 不带 keyword -> 清空全部搜索历史 * 2. 带 keyword= -> 删除单条关键字 @@ -64,9 +83,17 @@ export async function POST(request: NextRequest) { export async function DELETE(request: NextRequest) { try { const { searchParams } = new URL(request.url); + const user = searchParams.get('user')?.trim(); const kw = searchParams.get('keyword')?.trim(); - await db.deleteSearchHistory(kw || undefined); + if (!user) { + return NextResponse.json( + { error: 'User parameter is required' }, + { status: 400 } + ); + } + + await db.deleteSearchHistory(user, kw || undefined); return NextResponse.json({ success: true }, { status: 200 }); } catch (err) { diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 2452f39..b640cc2 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -149,7 +149,7 @@ export default function VideoCard({ }; const hideCheckCircle = from === 'favorites' || from === 'search'; - const alwaysShowHeart = from === 'favorites'; + const alwaysShowHeart = from !== 'favorites'; return ( )} {/* 搜索非聚合 - 集数圆形展示框 */} - {from === 'search' && ( + {episodes && episodes > 1 && !currentEpisode && (
{episodes} diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index b2a7c8a..90bd32c 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -206,7 +206,10 @@ export async function getSearchHistory(): Promise { // 如果配置为使用数据库,则从后端 API 获取 if (STORAGE_TYPE !== 'localstorage') { try { - return fetchFromApi('/api/searchhistory'); + const user = getUsername(); + return fetchFromApi( + `/api/searchhistory?user=${encodeURIComponent(user ?? '')}` + ); } catch (err) { console.error('获取搜索历史失败:', err); return []; @@ -240,12 +243,13 @@ export async function addSearchHistory(keyword: string): Promise { // 数据库模式 if (STORAGE_TYPE !== 'localstorage') { try { + const user = getUsername(); await fetch('/api/searchhistory', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ keyword: trimmed }), + body: JSON.stringify({ keyword: trimmed, user: user ?? '' }), }); } catch (err) { console.error('保存搜索历史失败:', err); @@ -276,7 +280,8 @@ export async function clearSearchHistory(): Promise { // 数据库模式 if (STORAGE_TYPE !== 'localstorage') { try { - await fetch('/api/searchhistory', { + const user = getUsername(); + await fetch(`/api/searchhistory?user=${encodeURIComponent(user ?? '')}`, { method: 'DELETE', }); } catch (err) { @@ -300,9 +305,15 @@ export async function deleteSearchHistory(keyword: string): Promise { // 数据库模式 if (STORAGE_TYPE !== 'localstorage') { try { - await fetch(`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`, { - method: 'DELETE', - }); + const user = getUsername(); + await fetch( + `/api/searchhistory?user=${encodeURIComponent( + user ?? '' + )}&keyword=${encodeURIComponent(trimmed)}`, + { + method: 'DELETE', + } + ); } catch (err) { console.error('删除搜索历史失败:', err); } diff --git a/src/lib/db.ts b/src/lib/db.ts index 971ed4d..3ce966a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -58,9 +58,9 @@ export interface IStorage { checkUserExist(userName: string): Promise; // 搜索历史相关 - getSearchHistory(): Promise; - addSearchHistory(keyword: string): Promise; - deleteSearchHistory(keyword?: string): Promise; + getSearchHistory(userName: string): Promise; + addSearchHistory(userName: string, keyword: string): Promise; + deleteSearchHistory(userName: string, keyword?: string): Promise; // 用户列表 getAllUsers(): Promise; @@ -184,26 +184,30 @@ class RedisStorage implements IStorage { } // ---------- 搜索历史 ---------- - private shKey = 'moontv:search_history'; - - async getSearchHistory(): Promise { - return (await this.client.lRange(this.shKey, 0, -1)) as string[]; + private shKey(user: string) { + return `u:${user}:sh`; // u:username:sh } - async addSearchHistory(keyword: string): Promise { + async getSearchHistory(userName: string): Promise { + return (await this.client.lRange(this.shKey(userName), 0, -1)) as string[]; + } + + async addSearchHistory(userName: string, keyword: string): Promise { + const key = this.shKey(userName); // 先去重 - await this.client.lRem(this.shKey, 0, keyword); + await this.client.lRem(key, 0, keyword); // 插入到最前 - await this.client.lPush(this.shKey, keyword); + await this.client.lPush(key, keyword); // 限制最大长度 - await this.client.lTrim(this.shKey, 0, SEARCH_HISTORY_LIMIT - 1); + await this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1); } - async deleteSearchHistory(keyword?: string): Promise { + async deleteSearchHistory(userName: string, keyword?: string): Promise { + const key = this.shKey(userName); if (keyword) { - await this.client.lRem(this.shKey, 0, keyword); + await this.client.lRem(key, 0, keyword); } else { - await this.client.del(this.shKey); + await this.client.del(key); } } @@ -371,16 +375,16 @@ export class DbManager { } // ---------- 搜索历史 ---------- - async getSearchHistory(): Promise { - return this.storage.getSearchHistory(); + async getSearchHistory(userName: string): Promise { + return this.storage.getSearchHistory(userName); } - async addSearchHistory(keyword: string): Promise { - await this.storage.addSearchHistory(keyword); + async addSearchHistory(userName: string, keyword: string): Promise { + await this.storage.addSearchHistory(userName, keyword); } - async deleteSearchHistory(keyword?: string): Promise { - await this.storage.deleteSearchHistory(keyword); + async deleteSearchHistory(userName: string, keyword?: string): Promise { + await this.storage.deleteSearchHistory(userName, keyword); } // 获取全部用户名